All findings · Lucene finding · LFV-979 · suggest-lookup-load-publishes-fields-before-stream-is-read

Suggest Lookup.load(): a load that throws leaves the lookup half-loaded, with the new count and part of the new model

When a suggester's load() throws part-way, the lookup keeps what it had read: a new TSTLookup loading a 2-word stored copy that is 1 byte short throws, yet reports getCount() 2 instead of 0, suggests "a" and throws NullPointerException for "b". Every model-reading load() assigns fields while still reading.

Summary

Priority
Low, rank 494A 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
minor
Since
3.1.0 (SOLR-2375, 2011); all six Lookups from 4.7.0introduced 2011-03-01 · over 15 years
Affected
3.1.0 to 10.6.0 and main
Status
documented
Found by
a fault-injection probe, then a bounded-exhaustive fault-injection check and a JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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

Background: how a suggester is saved and loaded

  1. A Lookup is 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 (their store() and load() return false).
  2. store() writes it to a stream: the entry count first, then the model (a ternary search tree, or an FST plus a few settings).
  3. load() reads such a copy back: "Discard current lookup data and load it from a previously saved copy". It throws IOException when 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.
Summary card in three panels. 1, load a stored copy that is cut short: a TSTLookup of a and b is stored (30 bytes), the copy is cut to 29 bytes and loaded into a new, empty TSTLookup; load() throws EOFException, which the app catches. 2, load() assigns first, then reads: line 276 sets count to 2, line 277 replaces the tree with a new empty root, line 278 reads the tree and throws, because the last byte, in node b's value, is missing; left behind are count 2 and the tree root, a (value 1), b (value null). 3, after the failed load(), executed on main with assertions on and off: getCount() expected 0, actual 2; lookup a expected none, actual a/1; lookup b expected none, actual NullPointerException. Also: the same with an intact copy whose stream throws IOException, the same pattern in the other suggesters, and with the fix 0 and no suggestions.
The repro on main 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.

The finding, step by step

  1. Build a TSTLookup from two words, a and b (weight 1 each), and store() it: a 30-byte copy, the count 2 and then the tree. The copy ends up 1 byte short, as when a store() is cut off by a crash.
  2. Load it into a new TSTLookup(). load() throws EOFException and you catch it. You expect the new lookup to stay empty: getCount() 0, no suggestions.
  3. Instead getCount() is 2, lookup("a") returns a/1 from the half-read copy, and lookup("b") throws NullPointerException. An IOException in the middle of a complete copy does the same.
  4. 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.

Who is affected

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

Terms

  • TSTLookup: a suggester on a ternary search tree. Each node (TernaryTreeNode.java:28-48) holds one character (splitchar), an optional token and val (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() writes count as 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.

The bug

  1. load(InputStream) wraps the stream, calls load(DataInput) and closes the stream in a finally block, also when the load fails (Lookup.java:150-157).
  2. 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.
  3. 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 but val == null.
  4. 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" throws NullPointerException.
  5. The other load() methods assign in stream order too: AnalyzingSuggester.java:614-619 (count, fst, maxAnalyzedPathsForOneInput, hasPayloads; FuzzySuggester inherits it), FreeTextSuggester.java:363-379 (count before its own separator and grams checks, totTokens before the FST), FSTCompletionLookup.java:304-308 and WFSTCompletionLookup.java:145-146 (count before the FST). In a new AnalyzingSuggester or FuzzySuggester whose stream ends or fails right after the FST, the new FST is live while maxAnalyzedPathsForOneInput is still 0, and some lookups throw NoSuchElementException. Nothing restores a field on the exception path.

The numbers

expected after the failed load()actual on main
new TSTLookup, copy of {a, b} 1 byte shortgetCount() 0, no suggestionsgetCount() 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 bytecount 5 and its own suggestions, or emptycount 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 4IllegalStateException "grams=2 is incorrect: original model was built with grams=3", count 3
FreeTextSuggester over a built model, copy cut inside the FSTthe old scorescount 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 settingthe old suggestionsthe new FST read with the old payload flag: key ab + separator + pab with no payload, or exceptions
FSTCompletionLookup / WFSTCompletionLookup, copy cut shortthe old suggestions with count 5the 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 Lookup after its load() threw, including a new instance it meant to swap in. Solr's SolrSuggester.init loads the stored file into the lookup it will serve, catches the IOException, logs "Loading stored lookup data failed, possibly not cached yet" and continues with that instance (it catches only IOException, so this does not cover FreeText's IllegalStateException); its build() 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 (TSTLookup and FSTCompletionLookup have no-argument constructors; the others take an analyzer and/or a temporary directory). Lookup and five of the six classes are @lucene.experimental (TSTLookup carries no tag).
  • Effect. After an exception the caller saw: TSTLookup serves part of the new tree and some lookups throw NullPointerException, also in a new instance. For FSTCompletionLookup, WFSTCompletionLookup, a new FreeTextSuggester, the FreeText rejected-model case and most Analyzing/Fuzzy cut points only getCount() 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 (an AssertionError with -ea). A new Analyzing/Fuzzy instance whose stream ends or fails right after the FST throws NoSuchElementException from some lookups. FreeTextSuggester over 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.

Minimal repro

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.

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

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());
  }
}
Command
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 failed load(): the new lookup stays empty.
Observed
getCount() after a failed load() expected:<0> but was:<2>, the stored copy's count (in the same state lookup("b") throws NullPointerException).

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

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

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 9.12.3 and 10.5.2 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
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

Observed output

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

Why it looks unintended

  • The new-instance swap #5558 describes is the repro: that instance ends neither empty nor loaded, and lookup() throws.
  • An IOException while a valid copy is read is what load() declares; nothing says the instance is unusable afterwards.
  • FreeTextSuggester changes getCount() 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 a NullPointerException: neither its previous (empty) state nor the stored model.
  • The trigger is in contract. An IOException while a valid copy is read is what load() 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) put count = input.readVLong() ahead of those checks (FreeTextSuggester.java:363), so a model it rejects with its own IllegalStateException still changes getCount().
  • Lucene reloads in place itself. TestAnalyzingSuggester calls suggester.load(is) on the instance it just built and stored (TestAnalyzingSuggester.java:1048, :1099, :1147, :1187). The module's 6 load() 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, and CompletionsTermsReader.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".

Suggested fix

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.
  • TSTLookup keeps 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/suggest module passes with assertions on and off: 32 suites, 237 tests, 7 skipped, 0 failures (TestPersistence, TestAnalyzingSuggester, TestFuzzySuggester, TestFreeTextSuggester, TestFSTCompletion, TestWFSTCompletion included).
  • :lucene:suggest:check -x test flagged one line only, the test's 101-character comment (google-java-format); with it wrapped onto two lines (no code change) check passes, 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.

Fix only TSTLookup.load

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.

Reset to the empty state when load() throws

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.

Document that the instance must be discarded after load() throws

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.

Verify the stored copy before using it (complement)

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

Downstream note

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.

How it was found

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 TSTLookup 68 of 69 prefixes, AnalyzingSuggester 87 of 88); the same on the branch_10_6 RC 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 IOException at 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, commits and downstream repositories2026-10-29a 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.NETno report; only the related items above
Open and recent PRs2026-10-29the 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 datenone changes a load() method; last lucene/suggest commit 2026-06-30, unrelated
Mailing lists2026-10-2920 tool queries plus manual queries over dev@, java-user@, issues@ and the Solr listsonly threads about a stored file that did not exist yet
JIRA archive2026-10-294 tool queries and 9 manual LUCENE and SOLR queriesLUCENE-5430 and the SOLR items above, none the same bug
Web2026-10-296 tool queries and 8 manual queriesno report

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 and a9779ef3c193 affectedthe 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 affectedthe portable harness fails 8 of 8; the exhaustive check finds 78 violating configurations
10.5.2, 9.12.3 affectedthe 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 affectedthe portable harness fails 8 of 8
4.6.1, 4.6.0 before LUCENE-5404fails 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 classesTSTLookup 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 onlyTSTLookup fails 2 of 2 (cut copy, read failure)
main + fix not affectedthe repro passes; the suggest module passes; the exhaustive check finds 0 violations

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate, priority low8-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), proceduralOpenAI 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 realOwn 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 lowBuilt 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 lowRe-ran the independent reproduction's JUnit (assertions on and off): 7 of 7 fail.
Registration check, credibility audit and repro minimisation keep, priority lowFresh 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 validatedThe 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, appliedRe-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, appliedOpenAI 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.

Diagram file

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

Sources and provenance

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

Finding record

LFV-979, snapshot of 2026-11-06

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 main a9779ef3c193, whose lucene/suggest is 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, the branch_10_6 RC and main.

Lucene and upstream sources

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-252
  • lucene/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-148
  • lucene/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-148
  • lucene/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-382
  • lucene/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)

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

Repro files

About the diagrams

It is made in parallel with this doc; every on-screen value comes from the code at 645d4fe14a20 or the diagram's executed probe.

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