TSTLookup.load() sets the entry count, replaces its tree with a new, empty one and reads the stored tree into it node by node, so each part is live as soon as it is read. When the stream ends or fails part-way, load() throws, but the new count and the half-built tree stay behind.
- A
Lookupis one of the suggest module's autocomplete structures, built from words with weights. Six of them (TSTLookup,AnalyzingSuggester,FuzzySuggester,FreeTextSuggester,FSTCompletionLookup,WFSTCompletionLookup) can save their model to a stream and load it back; the infix suggesters cannot (theirstore()andload()return false). store()writes it to a stream: the entry count first, then the model (a ternary search tree, or an FST plus a few settings).load()reads such a copy back: "Discard current lookup data and load it from a previously saved copy". It throwsIOExceptionwhen the read fails and says nothing about the state it leaves then. A common pattern is to load into a new instance and swap it in.
645d4fe14a20: the 30-byte stored copy of {a, b}, the load() that sets the count and a new tree before it reads, the read that fails at the last byte, what the application sees, and the fix. Select the still to open it at full size. Generated with AI, checked against the code.- Build a
TSTLookupfrom two words, a and b (weight 1 each), andstore()it: a 30-byte copy, the count 2 and then the tree. The copy ends up 1 byte short, as when astore()is cut off by a crash. - Load it into a new
TSTLookup().load()throwsEOFExceptionand you catch it. You expect the new lookup to stay empty:getCount()0, no suggestions. - Instead
getCount()is 2,lookup("a")returns a/1 from the half-read copy, andlookup("b")throwsNullPointerException. AnIOExceptionin the middle of a complete copy does the same. - Why:
load()sets the count first, then swaps in a new, empty tree and fills it node by node as it reads. The stream ended inside b's weight, so node b has its word but no weight. The other suggesters'load()methods also assign fields as they read.
Applications that store() and load() the suggest Lookups and keep using an instance after its load() threw: a stored copy cut short, an I/O error while a valid copy is read, or a FreeTextSuggester model saved with other settings. Opt-in classes, default settings, any size, deterministic. Rare: no public report since 2011.
Mechanism, terms and numbers798 words
The six suggest Lookups that implement store()/load() (TSTLookup, AnalyzingSuggester, FuzzySuggester, FreeTextSuggester, FSTCompletionLookup, WFSTCompletionLookup) can save their model to a stream and load it back; the infix suggesters' store()/load() just return false, so they are not affected. Lookup.load is documented as "Discard current lookup data and load it from a previously saved copy" and throws IOException "when fatal IO error occurs" (Lookup.java:246-252 on main 645d4fe14a20); it says nothing about the state after a throw. Each of the five load() methods (FuzzySuggester inherits one) assigns its fields one after another while it reads: TSTLookup.load sets count, replaces the tree with a new empty root and only then reads the tree into that live root (TSTLookup.java:276-278). So a load() that throws part-way (a stored copy cut short, an IOException while a valid copy is read, or FreeTextSuggester rejecting a model saved by a FreeTextSuggester with other grams or separator settings) leaves a mix of the old or initial state and the new model. A new TSTLookup loading a 2-word copy that is 1 byte short reports getCount() 2 instead of 0, suggests a/1 and throws NullPointerException for "b"; for most other classes only getCount() is wrong, with narrower payload and score mixes and a narrow window in which a new AnalyzingSuggester or FuzzySuggester throws NoSuchElementException from some lookups. TSTLookup has done this since its first load() (SOLR-2375, 3.1.0, 2011), all six classes since LUCENE-5404 (4.7.0, 2014). Reading into locals and assigning at the end fixes it (5 methods, +30/-16).
TSTLookup: a suggester on a ternary search tree. Each node (TernaryTreeNode.java:28-48) holds one character (splitchar), an optionaltokenandval(the word and its weight) and up to three children.getCount()is "the number of entries the lookup was built with" (Lookup.java:172-176).- Stored copy:
store()writescountas a VLong and then the tree in pre-order: per node its character, a flag byte, then the token and an 8-byte weight when the node has them (TSTLookup.java:241-272). For {a, b} that is 30 bytes; the last byte belongs to b's weight.
load(InputStream)wraps the stream, callsload(DataInput)and closes the stream in afinallyblock, also when the load fails (Lookup.java:150-157).TSTLookup.load(TSTLookup.java:275-280):count = input.readVLong();(:276),root = new TernaryTreeNode();(:277),readRecursively(input, root);(:278). The count and the new, empty root are live before a single node is read.readRecursively(:218-239) reads a node's character, flags, token and weight, then allocates each child and recurses. With the copy 1 byte short,readLong()for b's weight (:225) hits the end:EOFException. Node b exists with its token butval == null.lookup()collects every node with a token (TSTAutocomplete.java:97-148) and unboxes its weight,((Number) ttn.val).longValue()(TSTLookup.java:199,:205): "a" gives a/1, "b" throwsNullPointerException.- The other
load()methods assign in stream order too:AnalyzingSuggester.java:614-619(count,fst,maxAnalyzedPathsForOneInput,hasPayloads;FuzzySuggesterinherits it),FreeTextSuggester.java:363-379(countbefore its own separator and grams checks,totTokensbefore the FST),FSTCompletionLookup.java:304-308andWFSTCompletionLookup.java:145-146(countbefore the FST). In a newAnalyzingSuggesterorFuzzySuggesterwhose stream ends or fails right after the FST, the new FST is live whilemaxAnalyzedPathsForOneInputis still 0, and some lookups throwNoSuchElementException. Nothing restores a field on the exception path.
expected after the failed load() | actual on main | |
|---|---|---|
new TSTLookup, copy of {a, b} 1 byte short | getCount() 0, no suggestions | getCount() 2, lookup("a") a/1, lookup("b") NullPointerException |
TSTLookup holding {aa, ab, ba, bb, cア} (count 5), complete copy of {xa, xb, ya, ab} whose stream throws IOException at its middle byte | count 5 and its own suggestions, or empty | count 4, lookup("a") ab/50 only, lookup("x") NullPointerException |
FreeTextSuggester (bigrams, count 4) loads a complete trigram model saved by another FreeTextSuggester (grams=3) | count 4 | IllegalStateException "grams=2 is incorrect: original model was built with grams=3", count 3 |
FreeTextSuggester over a built model, copy cut inside the FST | the old scores | count 3; the old FST scored against the new token total (5 instead of 8): "bar" 0.25 becomes 0.4 |
AnalyzingSuggester / FuzzySuggester, cut in the last byte, after a model with the other payload setting | the old suggestions | the new FST read with the old payload flag: key ab + separator + pab with no payload, or exceptions |
FSTCompletionLookup / WFSTCompletionLookup, copy cut short | the old suggestions with count 5 | the old suggestions with count 4 |
Every cut stream is rejected (0 silent loads in the hunt's 6,210-check exhaustive run): the claim is failure atomicity, not acceptance of damaged data.
Exposure and likelihood285 words
- Who is hit. Code that keeps using a
Lookupafter itsload()threw, including a new instance it meant to swap in. Solr'sSolrSuggester.initloads the stored file into the lookup it will serve, catches theIOException, logs "Loading stored lookup data failed, possibly not cached yet" and continues with that instance (it catches onlyIOException, so this does not cover FreeText'sIllegalStateException); itsbuild()stores straight into the target file, so a store cut short by a crash leaves exactly this input for the next start. - Default or opt-in. Opt-in: the suggest module's
Lookups, with default settings (TSTLookupandFSTCompletionLookuphave no-argument constructors; the others take an analyzer and/or a temporary directory).Lookupand five of the six classes are@lucene.experimental(TSTLookupcarries no tag). - Effect. After an exception the caller saw:
TSTLookupserves part of the new tree and some lookups throwNullPointerException, also in a new instance. ForFSTCompletionLookup,WFSTCompletionLookup, a newFreeTextSuggester, the FreeText rejected-model case and most Analyzing/Fuzzy cut points onlygetCount()is wrong. Analyzing/Fuzzy cut in their last 1-2 bytes after a model with the other payload setting glue the payload to the key or return the key as the payload (anAssertionErrorwith-ea). A new Analyzing/Fuzzy instance whose stream ends or fails right after the FST throwsNoSuchElementExceptionfrom some lookups.FreeTextSuggesterover a built model scores the old FST with the new token total. - Scale. None needed: 2 entries, one thread, assertions on or off.
- How likely: rare. It needs a
load()that throws and continued use of that instance; upstream tests load only complete copies, and the recorded search found no report since 2011.
One JUnit test for lucene/suggest, public API only: store a TSTLookup of {a, b}, cut the copy by its last byte, load it into a new TSTLookup() (it throws), and assert getCount() is 0. It fails on main, 10.5.2 and 9.12.3 with assertions on and off, and passes with the fix.
/*
* 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;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringReader;
import java.util.Arrays;
import org.apache.lucene.search.spell.PlainTextDictionary;
import org.apache.lucene.search.suggest.tst.TSTLookup;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestTSTLookupFailedLoad extends LuceneTestCase {
public void testFailedLoadIntoNewLookup() throws Exception {
ByteArrayOutputStream stored = new ByteArrayOutputStream();
try (Directory dir = new ByteBuffersDirectory()) {
TSTLookup built = new TSTLookup(dir, "suggest");
built.build(new PlainTextDictionary(new StringReader("a\nb\n")).getEntryIterator());
built.store(stored);
}
byte[] cutShort = Arrays.copyOf(stored.toByteArray(), stored.size() - 1);
TSTLookup lookup = new TSTLookup();
expectThrows(IOException.class, () -> lookup.load(new ByteArrayInputStream(cutShort)));
// expected: a load() that threw leaves the new lookup empty (0); actual: the stored copy's count
assertEquals("getCount() after a failed load()", 0, lookup.getCount());
}
}
cp $REPRO/TestTSTLookupFailedLoad.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests TestTSTLookupFailedLoad -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and with -Ptests.asserts=false
- Expected
getCount()is 0 after the failedload(): the new lookup stays empty.- Observed
getCount() after a failed load() expected:<0> but was:<2>, the stored copy's count (in the same statelookup("b")throwsNullPointerException).
More files, environment, full output303 words
TestTSTLookupFailedLoad (46 lines with the license header, one test method, package org.apache.lucene.search.suggest). Public API only: TSTLookup, PlainTextDictionary, ByteBuffersDirectory, store(OutputStream), load(InputStream), getCount(). Deterministic: fixed input, an in-memory directory, one thread. The test asserts only the count, the smallest stable symptom; the NullPointerException from lookup("b") in the same state comes from a separate evidence probe (a main() that prints the state), not from the test.
Use the same test as the regression test of a fix, as its own class or as one method in TestPersistence, with its one comment line wrapped to google-java-format's 100 columns.
The hunt's larger evidence, not part of the repro: an 8-test JUnit over all six classes (cut copies, a stream that fails part-way, the FreeText rejected model), an independent 7-test JUnit over every failure point, and a bounded-exhaustive fault-injection check (6,210 checks per assertion mode).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 9.12.3 and 10.5.2 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestTSTLookupFailedLoad.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests TestTSTLookupFailedLoad -Ptests.seed=DEADBEEF -Ptests.asserts=true
TestTSTLookupFailedLoad > testFailedLoadIntoNewLookup FAILED
java.lang.AssertionError: getCount() after a failed load() expected:<0> but was:<2>
# the same message through the standalone runner on main (assertions on and off) and on 10.5.2 and 9.12.3
# with the fix (gradle, assertions on and off): 1 test, 0 failures
# evidence probe of the same state (not the repro)
built: getCount()=2 | lookup("a") -> [a/1] | lookup("b") -> [b/1]
stored copy: 30 bytes
loaded whole: getCount()=2 | lookup("a") -> [a/1] | lookup("b") -> [b/1]
new TSTLookup: getCount()=0 | lookup("a") -> [] | lookup("b") -> []
load(cut 29 bytes) threw java.io.EOFException
after the throw: getCount()=2 | lookup("a") -> [a/1] | lookup("b") -> threw java.lang.NullPointerException- The new-instance swap #5558 describes is the repro: that instance ends neither empty nor loaded, and
lookup()throws. - An
IOExceptionwhile a valid copy is read is whatload()declares; nothing says the instance is unusable afterwards. FreeTextSuggesterchangesgetCount()before its own check rejects a model; LUCENE-10292 fixed the build-side twin as a bug.
The full argument259 words
- A new instance is left half-loaded too. #5558 (LUCENE-4492, open) describes loading a new instance and swapping it in as today's way to reload a suggester (the issue is about safe publication of that swap). In the repro that instance ends with
getCount()2, one suggestion and aNullPointerException: neither its previous (empty) state nor the stored model. - The trigger is in contract. An
IOExceptionwhile a valid copy is read is whatload()declares (Lookup.java:250). These classes have no closed or unusable state. - FreeTextSuggester's validation is out of order. Its first
load()(LUCENE-5214,b50a2edbb23, 2013) checked separator and grams before assigning any field; LUCENE-5404 (b5926da2d67, 2014) putcount = input.readVLong()ahead of those checks (FreeTextSuggester.java:363), so a model it rejects with its ownIllegalStateExceptionstill changesgetCount(). - Lucene reloads in place itself.
TestAnalyzingSuggestercallssuggester.load(is)on the instance it just built and stored (TestAnalyzingSuggester.java:1048,:1099,:1147,:1187). The module's 6load()calls in tests all read complete copies, so no test asserts or contradicts any post-failure state. - The build side was fixed as a bug. LUCENE-10292 (#11328, 9.2.0, "Bug Fixes"): "Fix other FST based suggesters so that getCount() returned results consistent with lookup() during concurrent build()" (
lucene/CHANGES.txt:4289-4291).load()was not touched. - Lucene's newer suggester already loads this way.
NRTSuggester.load(the document suggester,NRTSuggester.java:309-327) reads every field into locals and constructs the object at the end, andCompletionsTermsReader.suggester()assigns it in one step (:64-69): the shape of the recommended fix.
The devil's-advocate check330 words
The intended-behaviour checker, a reviewer from another model family (OpenAI GPT-5.6) and the adjudication built the strongest case: Lookup.load promises nothing after it throws, and "fatal IO error" can be read as "the instance is unusable afterwards"; Lookup is @lucene.experimental; a cut stream is not "a previously saved copy", a trigram model is not a copy for a bigram suggester, and every damaged stream is detected; #5558 describes a new instance and a swap; on #6467 (LUCENE-5404) McCandless (they) called loading a previously built suggester and then building a new one over it "strange", and wrote about the repro's own pattern: "it's sort of weird to 1) create a new suggester class, and 2) call its load API; it's more natural to create the suggester, passing in a Dir/File where it should load its state from"; on #11328 dweiss (they) said the Lookups "seemed to be designed with a single-thread model in mind"; and for most classes only getCount() is wrong.
Why it fails: the classes define no unusable state, and the first consumer of TSTLookup.load (Solr's Suggester.init, added by the same SOLR-2375 commit) caught the exception and kept the instance; the "not a saved copy" point does not cover an IOException while a valid copy is read, and the FreeText rejection is the suggester's own check; the new-instance pattern is the repro, and that instance is not left empty; Lucene's own tests reload in place. The "sort of weird" remark is an API-design opinion, and the API as shipped is built for construct-then-load: FSTCompletionLookup's no-argument constructor is documented "This constructor should only be used to read a previously saved suggester." (FSTCompletionLookup.java:105), TestPersistence (:86-87) and TestFreeTextSuggester (:90-91) load into a new instance, and so does Solr's SolrSuggester.init. The count-only point is true and is why the priority is low, but TSTLookup throws from lookup(). What remains supports a low priority and possibly an "improvement" label, not "works as designed".
Recommended Read into locals, assign after the whole stream was read and checked
In each of the five load() methods, read every field into locals and assign them only after the whole stream was read and checked (+30/-16; FuzzySuggester inherits it). Validated: the repro passes with assertions on and off, the whole suggest module's tests (237) pass, and check -x test passes once the test's long comment line is wrapped.
public synchronized boolean load(DataInput input) throws IOException {
- count = input.readVLong();
- root = new TernaryTreeNode();
- readRecursively(input, root);
+ long newCount = input.readVLong();
+ TernaryTreeNode newRoot = new TernaryTreeNode();
+ readRecursively(input, newRoot);
+ root = newRoot;
+ count = newCount;
return true;
}All options and trade-offs5 options · 612 words
One fix was built and validated; four alternatives were considered and not built.
Read into locals, assign after the whole stream was read and checked (validated by the checks) (recommended)
Five load() methods (TSTLookup, AnalyzingSuggester, FreeTextSuggester, FSTCompletionLookup, WFSTCompletionLookup) read into locals and assign the fields at the end; FuzzySuggester inherits it. In FreeTextSuggester this also puts the separator and grams checks back before any assignment. Lucene's document suggester already loads this way (NRTSuggester.load). The TSTLookup part:
public synchronized boolean load(DataInput input) throws IOException {
- count = input.readVLong();
- root = new TernaryTreeNode();
- readRecursively(input, root);
+ long newCount = input.readVLong();
+ TernaryTreeNode newRoot = new TernaryTreeNode();
+ readRecursively(input, newRoot);
+ root = newRoot;
+ count = newCount;
return true;
}Trade-offs
- Several plain field writes at the end, as the build side does since LUCENE-10292: not one atomic snapshot for a thread that looks up while
load()publishes. TSTLookupkeeps the old tree until the new one is read, so peak memory during a load is old + new tree (today the old tree is dropped first); the FST classes already hold the old FST until their assignment.- No stored-format change and no cost on the lookup path.
What the checks validated
- Scratch worktree of
645d4fe14a20, gradle, OpenJDK 25: the repro test fails on the stock tree and passes with the fix, with assertions on and off. - With the fix and the test, the whole
lucene/suggestmodule passes with assertions on and off: 32 suites, 237 tests, 7 skipped, 0 failures (TestPersistence,TestAnalyzingSuggester,TestFuzzySuggester,TestFreeTextSuggester,TestFSTCompletion,TestWFSTCompletionincluded). :lucene:suggest:check -x testflagged one line only, the test's 101-character comment (google-java-format); with it wrapped onto two lines (no code change)checkpasses, so the five fixed sources are clean. Wrap that comment before the test goes into a PR; the assertions-off module run used the wrapped copy.- Earlier, the finder's 8 tests and an independent reproducer's 7 tests passed with the same change, and the exhaustive fault-injection check found 0 mixed states in 6,210 checks.
- An independent fact check repeated the stock and fixed gradle runs, the module tests and the check in its own scratch worktree, with the same results.
The same change in TSTLookup alone. Not built.
Trade-offs
Removes the broadest throwing symptom, TSTLookup's NullPointerException at most failure points, but leaves the narrow window in which a new AnalyzingSuggester or FuzzySuggester throws NoSuchElementException from some lookups, and the count-only and the score and payload mixes of the other five classes.
Catch, clear every field, rethrow. Not built.
Trade-offs
Matches "Discard current lookup data" literally, but drops the previous model a caller may still want and needs a per-class reset that is easy to get wrong.
Javadoc only. Not recommended alone.
Trade-offs
Changes no behaviour: a caller that swaps in a new instance already discards it, and one that does not still serves the half-loaded state. A maintainer may still prefer it: #16951 (open, 2026-10-29) proposes the same kind of answer one level down, a javadoc saying that an IndexInput is in an undetermined state after any IOException while reading.
#6493 (LUCENE-5430, open) proposes a header check before loading; a checksum footer would also catch a cut copy. Not built.
Trade-offs
Does not cover an IOException in the middle of a valid copy, and FreeTextSuggester already checks its header yet assigns count early, so it would sit on top of the recommended fix, not replace it.
Solr's SolrSuggester loads its stored file at startup, logs a failed load and keeps serving that same lookup, and it stores without a temporary file, so a store cut short by a crash leads straight to this half-loaded state.
An exception-safety planning probe loaded stored suggester models cut at every length and found all seven Lookup configurations left mixed. The hunt then injected a cut or an IOException at every byte of every stored model into loaded, built and new instances (6,210 checks, 78 violating configurations, 0 with the fix) and reduced it to a JUnit.
The method in full162 words
Method: non-formal first signal, then semi-formal: a planning probe followed by a bounded-exhaustive fault-injection check (the hunt's hunt unit for suggest lookups whose failed build or load leaves mixed state, 2026-10-28, main a9779ef3c193).
- Lead. A planning probe for exception safety loaded a stored model cut at every prefix length into a built instance on the stock jars (assertions on and off): all 7 Lookup configurations were left mixed (for example
TSTLookup68 of 69 prefixes,AnalyzingSuggester87 of 88); the same on thebranch_10_6RC jars. - Exhaustive check. A translation-validation harness on the stock jar: 6 Lookups x payload flags x previous state (loaded, built, new) x every cut length and an
IOExceptionat every byte; 6,210 checks per assertion mode, the oracle accepting the previous or the empty state: 78 violating configurations, 0 silent loads, 0 violations with the fix. - JUnit. Reduced to an 8-test public-API JUnit; the hunt's repro-minimisation step reduced it further to the one test above.
No prior report found (searched 2026-10-29): 61 tool queries plus 38 manual GitHub, JIRA and web searches over issues, PRs and commits, downstream repositories, the Lucene and Solr lists, the JIRA archive and the web; 17 hits assessed. Nearest: #6493 (verify a stored file before loading) and #11328 (the build-side fix).
Searches5 searches
Related, not reports of this bug: #6493 / LUCENE-5430 (open; header check before load: detection, not failure atomicity); #5558 / LUCENE-4492 (open; immutable suggesters, swap in a new instance, which this bug still leaves half-loaded); #11328 / LUCENE-10292 (made build() publish count and FST together; load() untouched); #6467 / LUCENE-5404 (gave load() its current shape; no failure discussion); SOLR-2173, SOLR-2375, SOLR-11139, SOLR-10937; dev@ 2015 and solr-user@ 2013 threads about a stored file that did not exist yet. Tracking issue #16937 does not list it.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs, commits and downstream repositories | 2026-10-29 | a scripted prior-report search in two runs (24 issue/PR, 3 commit and 4 downstream queries among 61; 1,071 distinct hits, 0 with high overlap) and 21 manual searches over apache/lucene, apache/solr, Elasticsearch, OpenSearch and Lucene.NET | no report; only the related items above |
| Open and recent PRs | 2026-10-29 | the changed files of all 414 open apache/lucene PRs; closed PRs updated since 2026-10-15; the 12 PRs created since 2026-10-27; commits under lucene/suggest by author date | none changes a load() method; last lucene/suggest commit 2026-06-30, unrelated |
| Mailing lists | 2026-10-29 | 20 tool queries plus manual queries over dev@, java-user@, issues@ and the Solr lists | only threads about a stored file that did not exist yet |
| JIRA archive | 2026-10-29 | 4 tool queries and 9 manual LUCENE and SOLR queries | LUCENE-5430 and the SOLR items above, none the same bug |
| Web | 2026-10-29 | 6 tool queries and 8 manual queries | no report |
Introduced in 3.1.0 by SOLR-2375 (e71e6823520, 2011), which gave TSTLookup its first load(); widened to all six Lookups in 4.7.0 by LUCENE-5404 (b5926da2d67, 2014), which made the count the first field every model-reading load() sets. Fails on every executed release that has the API, 3.1.0 to 10.6.0 (the branch_10_6 RC jars), and on main; not a regression.
Versions tested8 rows
Introduced by e71e6823520 (trunk; branch_3x merge ab2691c7740), "SOLR-2375 Store & Load functionality for Suggester Lookup implementations" (Andrzej Bialecki, 2011-03-01; a Solr JIRA issue from before GitHub PRs, not migrated). Its TSTLookup.load replaces the tree and then fills it from the stream. First release 3.1.0 (TSTLookup then in solr-core; first Lucene artifact lucene-spellchecker 3.3.0).
Widened by b5926da2d67 (branch_4x fdf4595051a), LUCENE-5404 = #6467 (Areek Zillur, 2014-02-07, first released in 4.7.0): getCount() and a leading count = input.readVLong() in every model-reading load() (in FreeTextSuggester right after its codec header check). That made FSTCompletionLookup and WFSTCompletionLookup (until then one stream read before any assignment) mixed and put FreeTextSuggester's count ahead of its checks. Per site in between: AnalyzingSuggester's trailer fields from 4.1.0 (LUCENE-4481) and its payload flag from 4.3.0 (LUCENE-4820); FreeTextSuggester's totTokens-before-FST order with the class in 4.6.0 (LUCENE-5214). The repro's symptom (getCount()) applies from 4.7.0; the partial tree and the NullPointerException are older.
Range. The releases in the table were executed with the follow-up's version-portable harness (8 cases, public API, through reflection over the three load() signatures); the releases between them were read at their release tags (same load() bodies).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 and a9779ef3c193 | affected | the one-test repro fails (gradle with assertions on, and off in the fact check's run; standalone, assertions on and off); the finder's 8 tests fail 8 of 8 |
branch_10_6 RC jars | affected | the portable harness fails 8 of 8; the exhaustive check finds 78 violating configurations |
| 10.5.2, 9.12.3 | affected | the one-test repro fails (assertions off; on and off in the fact check's runs); the finder's 8 tests fail 8 of 8 |
| 10.0.0, 9.0.0, 8.11.4, 7.7.3, 6.0.0, 5.5.5, 4.10.4, 4.7.0 | affected | the portable harness fails 8 of 8 |
| 4.6.1, 4.6.0 | before LUCENE-5404 | fails 5 of 8: FST/WFSTCompletionLookup load atomically, the FreeText rejected model keeps its count |
| 4.5.1, 4.3.0, 4.2.1, 4.1.0, 4.0.0 (lucene-suggest); 3.6.2, 3.6.0 (lucene-spellchecker) | fewer classes | TSTLookup fails (partial tree, NullPointerException); Analyzing from 4.1.0, payload flag from 4.3.0; FST/WFSTCompletionLookup pass |
| 3.5.0, 3.3.0 (lucene-spellchecker); solr-core 3.1.0 | TSTLookup only | TSTLookup fails 2 of 2 (cut copy, read failure) |
| main + fix | not affected | the repro passes; the suggest module passes; the exhaustive check finds 0 violations |
Several hunt checks reached the same diagnosis: an independent reproducer, an intended-behaviour devil's advocate, an adjudication (medium confidence) and a credibility audit (kept at low priority), and reading-only reviews by another model family (OpenAI GPT-5.6). This doc re-ran the one-test repro and validated the fix with gradle; an independent fact check ran it all again.
Checks and verdicts435 words
The cross-model reviews were reading-only: they could not run Java. The refute-lens review returned a procedural "not real, medium" while agreeing on the root cause; a separate re-run failed 8 of 8. An executing cross-model review is still open.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate, priority low | 8-test JUnit fails 8 of 8 on main (assertions on and off), 10.5.2 and 9.12.3; exhaustive check: 78 violating configurations in 6,210 checks, 0 with the fix. |
| cross-model review, refute lens | not real (medium confidence), procedural | OpenAI GPT-5.6, reading-only; agreed on the root cause and that a failing stream over store() output is a public-API path; its point that load() promises nothing after a throw set the low priority. A separate re-run failed 8 of 8. |
| Independent reproducer | real | Own JUnit over every failure point fails 7 of 7 on main, 10.5.2 and 9.12.3 (TSTLookup 142 of 143 points, 40 with lookups that throw); the fix passes 7 of 7. |
| Cross-model review, works-as-designed lens | real (medium confidence) | OpenAI GPT-5.6, reading-only; cited LUCENE-10292 and the rebuild test's invariant. A separate re-run failed 8 of 8. |
| Intended-behaviour devil's advocate | real, priority low | Built the strongest works-as-designed case and found it does not hold; the JUnit fails 8 of 8. |
| adjudication | confirmed (medium confidence), impact minor, priority low | Re-ran the independent reproduction's JUnit (assertions on and off): 7 of 7 fail. |
| Registration check, credibility audit and repro minimisation | keep, priority low | Fresh seeds: the finder's JUnit 8 of 8 fail, the independent reproduction's 7 of 7, the fix 8 of 8 pass; the one-test repro fails on 645d4fe14a20 (assertions on and off) and passes with the fix. |
| brief re-runs | reproduced; fix validated | The one-test repro on main 645d4fe14a20 (gradle with assertions on; standalone with assertions on and off), 10.5.2 and 9.12.3 (assertions off); the evidence probe; the fix with the repro, the whole suggest module (237 tests, assertions on and off) and check -x test. |
| fact check | substantively correct; five wording fixes, applied | Re-ran in its own scratch worktree: the gradle command on the stock tree (assertions on and off), the standalone runner on main, 10.5.2 and 9.12.3 (assertions on and off), the evidence probe, the finder's 8 tests, the fix with the repro, the module tests and check -x test: all as stated. |
| cross-model fact check | real as described; three wording fixes, applied | OpenAI GPT-5.6, reading-only: read the code, the runs and pre-generated command outputs; found the scope and version-label slips and the option B claim corrected in this version. |
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 509 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,681 words
- Registry title
- Suggest Lookup.load() assigns its count and model fields while it is still reading the stream, so a load that throws (a stored copy cut short, a read failure, or FreeTextSuggester rejecting a model saved with other settings) leaves a mix of the previous and the new model: TSTLookup serves part of the new tree and some lookups throw NullPointerException, AnalyzingSuggester/FuzzySuggester can decode the new FST with the old payload flag, FreeTextSuggester scores the old FST with the new token total, and FST/WFSTCompletionLookup report the new count over the old FST
- Area
- suggest / Lookup persistence (load() failure atomicity: TSTLookup, AnalyzingSuggester, FuzzySuggester, FreeTextSuggester, FSTCompletionLookup, WFSTCompletionLookup)
- Priority
- Low, rank 494: A failed Lookup.load() leaves mixed old and new suggester state with some NullPointerException lookups, a minor keep-using-after-failure bug of the same class as LFV-784, so it follows that sibling.
- Impact class
- minor
- Impact note
- Applications that use the opt-in suggest Lookups with store()/load() (default constructors and settings, any size, -ea and -da alike) and keep using an instance after its load() threw (a stored copy cut short, for example by an interrupted store(), an I/O error while a valid copy is read, or a FreeTextSuggester model saved with another grams or separator setting) get a state that is neither the previous model nor an empty lookup: for FST/WFSTCompletionLookup, FreeTextSuggester loaded into a fresh instance, the FreeText rejected-model case and most AnalyzingSuggester/FuzzySuggester cut points only getCount() is wrong, while TSTLookup serves part of the new tree and throws NullPointerException on some prefixes (also when the load went into a new instance), AnalyzingSuggester/FuzzySuggester cut in their last 1-2 bytes, after a model with the other payload setting, return the payload glued to the key or the key as the payload (AssertionError with -ea), and FreeTextSuggester loaded over a built model scores the old FST with the new token total; load() does throw first, and a later successful load() assigns every field again [read]; every release from 3.1.0 (TSTLookup) and from 4.7.0 (all six classes) through 10.6.0.
- Summary
- You build a TSTLookup from {aa, ab, ba, bb, cア}, then call load() with a stored copy of another model that is cut short or whose stream throws IOException part-way, and catch the exception. You expect the lookup to hold either its previous data or no data, but it reports the new model's getCount() and serves part of the new tree, and some lookups throw NullPointerException; the same happens when you load into a newly constructed TSTLookup. The other Lookups do the same in their own way, because each load() assigns count and model fields one after another as it reads: AnalyzingSuggester/FuzzySuggester can decode the new FST with the old payload flag (payload glued to the key), FreeTextSuggester scores the old FST with the new token total and changes getCount() even when it rejects a complete model saved with other grams/separator settings, and FST/WFSTCompletionLookup report the new count over the old FST.
- Introduced
- e71e68235205f962cf9ff3bbc2678c0084731109 (trunk, svn r1075804; branch_3x merge ab2691c7740632c94eb6477642e6ecfa65d1acf6, r1075841), JIRA SOLR-2375 'Store & Load functionality for Suggester Lookup implementations' (author Andrzej Bialecki; a Solr issue from before GitHub pull requests, not migrated to apache/lucene) [read: git log and blame in the full-history clone; exec boundary: the Lucene 3.2.0 artifacts have no suggest API, solr-core 3.1.0 fails]: it gave TSTLookup its first real load(), which replaces the tree with 'root = new TernaryTreeNode()' and only then fills it from the stream. Widened to all six fix sites by b5926da2d67820f5fdf8c8ec9b2e642b74d12070 (trunk r1565810; branch_4x fdf4595051ada90409a47f8259a6d6e17d4711be), LUCENE-5404 = apache/lucene#6467 (JIRA patch by Areek Zillur, no pull request) [read; exec boundary: 4.6.1 fails 5 of 8 cases, 4.7.0 fails 8 of 8, -ea and -da]: it made every load() start with 'count = input.readVLong()', which turned FST/WFSTCompletionLookup's single-assignment loads into mixed-state loads and put FreeTextSuggester's count ahead of its own separator/grams checks. Per site in between [read]: AnalyzingSuggester's trailer fields after the FST came with LUCENE-4481 (dc49949558d, 2012-10-21, apache/lucene#5547) and LUCENE-4820 (234fa47dba4, 2013-03-13, apache/lucene#5885, the hasPayloads byte); FreeTextSuggester's totTokens-before-FST order came with the class itself, LUCENE-5214 (b50a2edbb23, 2013-10-02, apache/lucene#6278) (2011-03-01 (e71e6823520, author and commit date; TSTLookup); 2012-10-21 and 2013-03-13 (AnalyzingSuggester trailer fields); 2013-10-02 (FreeTextSuggester); 2014-02-07 (b5926da2d67, LUCENE-5404, author and commit date: count first in every load()))
- First affected version
- 3.1.0 [exec] (Lucene/Solr 3.1.0, tag 2011-03-30; TSTLookup then lived in solr-core; the first Lucene artifact with it is lucene-spellchecker 3.3.0 [exec]). Per site: AnalyzingSuggester 4.1.0 (the payload symptom 4.3.0), FreeTextSuggester 4.6.0, FST/WFSTCompletionLookup and FreeTextSuggester's rejected-model case 4.7.0 [exec]; all six fix sites from 4.7.0; 4.6.1 is the last release whose FST/WFSTCompletionLookup.load are atomic [exec]
- Affected versions
- Every release that ships a Lookup with store/load: solr-core 3.1.0-3.2.0, lucene-spellchecker 3.3.0-3.6.2, all 132 lucene-suggest versions on Maven Central from 4.0.0-ALPHA to 10.5.2 (the last release before 10.6.0), 10.6.0 (branch_10_6 4ae946e5a9b) and main a9779ef3c193; not fixed upstream. [exec] the follow-up's version-portable harness LoadHistory (8 mirrored cases, public API, called by reflection over load(File), load(InputStream) and load(DataInput)) fails on solr-core 3.1.0, 3.3.0, 3.5.0, 3.6.0, 3.6.2, 4.0.0, 4.1.0, 4.2.1, 4.3.0, 4.5.1, 4.6.0, 4.6.1, 4.7.0, 4.10.4, 5.5.5, 6.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, the branch_10_6 RC 4ae946e5a9b and main, with more failing cases in later releases (2 of 2 applicable in 3.x, 5 of 8 in 4.6.x, 8 of 8 from 4.7.0), and its fix control passes on main; the finder's JUnit fails 8 of 8 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main; the independent reproduction's JUnit fails 7 of 7 on 9.12.3, 10.5.2 and main; the translation validation finds 78 violating configurations on main and on the branch_10_6 RC jars. [read] the load() bodies in every release tag of the history clone; releases neither executed nor read in between are inferred from identical load() bodies on both sides. Upstream main 645d4fe14a20 (2026-10-28) has no commit under lucene/suggest after 2026-06-30 (author date). Executed only on aarch64 Linux with JDK 25
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-29, read-only; the prior-report record verdict new, plus the finder's search at 2026-10-28, the independent reproduction's and the intended-behaviour check's searches, the adjudication's check and a recheck): a scripted prior-report search in two runs (24 GitHub issue/PR, 3 commit, 4 downstream-repository, 20 mailing-list and 4 JIRA-archive queries, 6 web queries; 1,071 distinct hits, 0 high overlap), 21 manual GitHub searches, 9 JIRA searches, searches of the Lucene and Solr lists, 8 web searches, the changed files of all 414 open apache/lucene PRs and of the closed PRs updated since 2026-10-15, and the upstream commits API for lucene/suggest compared by author date (last commit b6ca26f121, authored 2026-06-30, unrelated); 17 hits assessed, none the same bug. Recheck: none of the 12 apache/lucene PRs created since 2026-10-27 changes a file under lucene/suggest (changed-file lists read), none of the 60 issues and PRs updated since then names these classes, and main 645d4fe14a20 has no lucene/suggest commit after 2026-06-30 by author date (the GitHub search API was rate-limited, so the recheck used the issues, pulls and commits listings). Related, not this bug: open #6493 / LUCENE-5430 (suggesters should verify a stored file before loading it: detection, not failure atomicity), open #5558 / LUCENE-4492 (immutable suggesters; load a new instance and swap it in, which this bug still affects), closed #11328 / LUCENE-10292 (made build() publish count and FST together; load() untouched), #6467 / LUCENE-5404 (origin of the current load() shape; no failure discussion), SOLR-2173, SOLR-2375, SOLR-11139, SOLR-10937, and 2013 and 2015 list threads about a missing stored file
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28; runs of 2026-10-30) through gradle in a scratch worktree (assertions on; the fact check's gradle run with assertions off fails the same way) and through a standalone runner (assertions on and off); the 9.12.3 and 10.5.2 release jars (assertions off; the fact check ran them with assertions on and off). The hunt ran on maina9779ef3c193, whoselucene/suggestis identical; the follow-up's version-portable harness ran on 23 releases (22 with the API; 3.2.0's Lucene artifacts have none) from solr-core 3.1.0 to 10.5.2, thebranch_10_6RC and main.
- Finding record
lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:244-251 (at a9779ef3c193) (load javadoc: 'Discard current lookup data and load it from a previously saved copy. Optional operation.', '@throws IOException when fatal IO error occurs'), :171-176 (getCount javadoc: 'Get the number of entries the lookup was built with')lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:275-280 (at a9779ef3c193) (count :276 and a new root :277 before readRecursively :278)lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:613-621 (at a9779ef3c193) (count :614, fst :617, maxAnalyzedPathsForOneInput :618, hasPayloads :619; FuzzySuggester inherits load)lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FreeTextSuggester.java:361-382 (at a9779ef3c193) (count :363 before the separator check :364-370 and the grams check :372-376; totTokens :377 before the FST :379)lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:303-310 (at a9779ef3c193) (count :304 before the FST :305-308)lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/WFSTCompletionLookup.java:144-148 (at a9779ef3c193) (count :145 before the FST :146)
- The finding in 30 seconds
lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:150-157, (at 645d4fe14a20) :172-176, :246-252lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:183-209, (at 645d4fe14a20) :218-239, :241-280; tst/TernaryTreeNode.java:28-48; tst/TSTAutocomplete.java:97-148lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:613-621; (at 645d4fe14a20) analyzing/FreeTextSuggester.java:361-382; fst/FSTCompletionLookup.java:303-310; fst/WFSTCompletionLookup.java:144-148lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:931-939 (at 645d4fe14a20) (store/load return false); analyzing/FreeTextSuggester.java:140, :142 (grams and separator are final)
- Who is affected
lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:38; (at 645d4fe14a20) analyzing/AnalyzingSuggester.java:98; analyzing/FuzzySuggester.java:64; analyzing/FreeTextSuggester.java:103; fst/FSTCompletionLookup.java:71; fst/WFSTCompletionLookup.java:60 (@lucene.experimental)
- Why it looks unintended
lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:246-252; (at 645d4fe14a20) analyzing/FreeTextSuggester.java:361-382lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestAnalyzingSuggester.java:1048, (at 645d4fe14a20) :1099, :1147, :1187; TestPersistence.java:86-87; analyzing/TestFreeTextSuggester.java:90-91 (grep of the module's tests: 6 load() calls)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/fst/FSTCompletionLookup.java:105; (at 645d4fe14a20) document/NRTSuggester.java:309-327; document/CompletionsTermsReader.java:64-69
- Suggested fix
lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:275-280; (at 645d4fe14a20) analyzing/FreeTextSuggester.java:362 (header check)github.com/apache/lucene/issues/16951 (Uwe Schindler, opened 2026-10-29, open; read 2026-10-30)
- TestTSTLookupFailedLoad.java (lucene/suggest, 1 test):
repro/LFV-979/src/org/apache/lucene/search/suggest/TestTSTLookupFailedLoad.java(46 lines)
It is made in parallel with this doc; every on-screen value comes from the code at 645d4fe14a20 or the diagram's executed probe.
assets/LFV-979/bug.png(509 KB)
