All findings · Lucene finding · LFV-784 · analyzingsuggester-failed-build-keeps-new-payload-flag

AnalyzingSuggester and FuzzySuggester: a failed rebuild with the other payload setting leaves the old suggestions decoded in the wrong format

A failed AnalyzingSuggester or FuzzySuggester rebuild from input with the other payload setting leaves the old suggestions read in the new format: foo with payload p1 comes back as the text foo<U+001F>p1 with payload null. build() switches the payload flag before reading input, the suggestions last.

Summary

Priority
Low, rank 493The experimental analyzing and fuzzy suggesters misdecode a stale FST only after a failed rebuild that also changes the payload setting, which is rarer than the Medium LFV-646, so it goes with its sibling LFV-601.
Impact
silent wrong results
Since
4.3.0 (LUCENE-4820, 2013)introduced 2013-03-13 · over 13 years
Affected
4.3.0 to 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
code reading, confirmed by a bounded-exhaustive fault-injection harness

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

AnalyzingSuggester stores each payload inside the suggestion's FST output, behind the reserved separator U+001F, and a field hasPayloads tells lookups whether to split there. build() overwrites that field with the new input's setting first and replaces the FST last, so a rebuild that throws in between leaves the old FST read with the new setting.

Diagram of an AnalyzingSuggester rebuild: build() first sets hasPayloads to false (line 394), the input read then throws, so the old FST holding foo U+001F p1 stays and lookup returns the text foo<U+001F>p1 with payload null instead of foo with payload p1.
One suggester rebuilt from a file without payloads: the payload flag flips at the start of build(), the read fails, the old FST stays, and lookup("fo") returns foo<U+001F>p1 with no payload. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You build an AnalyzingSuggester from a suggestion file with one line foo<TAB>10<TAB>p1 (text, weight, payload). lookup("fo") returns foo, weight 10, payload p1.
  2. You rebuild the same instance from a new file without the payload column, bar<TAB>3, and reading it fails after that line: build() throws IOException. You keep serving the instance and expect the old answer, or none.
  3. Instead lookup("fo") returns the text foo<U+001F>p1 with payload null: the payload glued to the text behind a reserved separator. store() and load() keep it.
  4. The other direction (old build without payloads, failed rebuild with payloads) returns empty text with foo as the payload, or throws AssertionError from lookup with assertions on.
  5. Why: build() sets the payload flag before it reads any input (AnalyzingSuggester.java:394) but swaps in the new suggestions only at the end (:589-590). Rebuilds with the same payload setting are fine, and the next successful build repairs it.

Who is affected

Applications that rebuild one long-lived AnalyzingSuggester or FuzzySuggester from input whose payload setting changed (a suggestion file that gained or lost its payload column, a payloadField added or removed) and keep serving it after that rebuild throws, or look it up during the rebuild. Every suggestion stays wrong until the next successful build. Opt-in payloads, experimental API, rare.

Mechanism, terms and numbers791 words

AnalyzingSuggester.build() writes the new input's payload setting into the field hasPayloads right after its contexts check, before it reads any input (AnalyzingSuggester.java:394), before the input loop, the analysis, the offline sort and the FST compilation, all of which can throw; the new FST and count are published only at the end (:589-590). Lookups and store() read the field to decide whether each FST output holds text<U+001F>payload or just the text. So when a rebuild whose input has the other payload setting throws, the instance keeps the old FST but decodes it with the new flag: built from foo<TAB>10<TAB>p1, then a failed rebuild from a file without payloads, lookup("fo") returns the text foo<U+001F>p1 with payload null, and store()/load() keep it (shown by a separate public-API probe); the other direction returns empty text with foo as the payload, or trips the class's own assert sepIndex != -1 (:633). FuzzySuggester inherits build(). A lookup made while such a rebuild is still reading its input sees the same. Every release since payloads were added in 4.3.0 (LUCENE-4820, 2013); LUCENE-10292 (9.2.0) moved count next to the FST but left this flag. Fix: keep the flag in a local and assign the field next to fst and count.

Terms

  • AnalyzingSuggester / FuzzySuggester: FST-based suggesters in the suggest module (FuzzySuggester extends AnalyzingSuggester and inherits build()). build(InputIterator) (or build(Dictionary)) builds the suggestions; lookup(key, ...) returns LookupResults with a text (key), a weight (value) and an optional payload; store() / load() save and restore the built state.
  • Payloads: optional bytes per suggestion. An input says whether it has them with hasPayloads(). FileDictionary decides from the first line of the file ("In order to have payload enabled, the first entry has to have a payload"); DocumentDictionary from whether a payloadField is configured.
  • How the FST stores them: with payloads, each FST output is text bytes, U+001F, payload bytes; without, just the text bytes. U+001F is reserved: build() rejects it in a payload-enabled text and lookup rejects it in keys.

The bug

  1. Right after its contexts check (AnalyzingSuggester.java:390-392), build() assigns the field: hasPayloads = iterator.hasPayloads(); (:394).
  2. Then it reads the input (:415-485: next() may throw IOException; the analysis may throw; an entry is rejected with IllegalArgumentException for U+001F with payloads on (:465-470), an over-long analyzed form (:424) or, with payloads on, an over-long surface form (:441), or with UnsupportedOperationException for a weight outside [0, Integer.MAX_VALUE] (:913-914)), sorts it through the temp directory (:490) and compiles the FST.
  3. Only then are the new FST and count published: fst = ...; count = newCount; (:589-590). If anything above throws, fst and count still describe the previous build.
  4. getLookupResult (:623-648) splits each output at U+001F only if (hasPayloads) and asserts the separator is there (assert sepIndex != -1, :633); sameSurfaceForm (:650-665) branches on the same field; store() writes the flag next to the FST (:608) and load() reads it back (:619).
  5. Result: flag newly false: the old output foo<U+001F>p1 is returned whole as the text, payload null. Flag newly true: the old output foo has no separator; with assertions on lookup throws AssertionError, with them off the text is empty and foo is returned as the payload (the payload is copied from the start of the output's bytes, :638).
  6. The flag is written before the input is read, so a lookup on another thread while such a rebuild is still in next() sees the same mixture without any failure.
  7. A failed build can also raise maxAnalyzedPathsForOneInput (:484), as every build does (it is never reset in build()); it only sizes the lookup's top-N search queue (:786) and plays no part in decoding.

What lookups return (main a9779ef3c193)

suggesterprevious buildrebuild that throwslookup afterwardsexpected
Analyzingfoo 10 p1bar 3 (no payloads), IOException after line 1foo<U+001F>p1, weight 10, payload null (also after store+load)foo, 10, p1
Analyzingfoo 10 (no payloads)bar 3 q, b<U+001F>z 2 r: IllegalArgumentExceptionAssertionError (-ea); empty text, 10, payload foo (-da)foo, 10, null
Fuzzyfoobar 10 p1bar 3, baz -1 (no payloads): UnsupportedOperationExceptionfoobar<U+001F>p1, 10, nullfoobar, 10, p1
Analyzingfoo 10 p1bar 3 q (payloads too), IOException (control)foo, 10, p1same
Analyzingfoo 10 p1none: lookup while bar 3 is still being readfoo<U+001F>p1, 10, nullfoo, 10, p1
Exposure and likelihood281 words
  • Who is exposed. Applications that keep one AnalyzingSuggester or FuzzySuggester and rebuild it in place (an established pattern for successful rebuilds: a committer wrote on java-user@ in 2013 that build() "fully replaces all internal suggester state every time you call it"; the state after a failed build is not documented either way), whose input changes its payload setting between builds, and that keep serving the instance after that rebuild throws, or serve lookups while it runs.
  • What goes wrong. Every suggestion of the instance, not some: all old outputs are decoded in the wrong format. Payload bytes appear inside the user-visible suggestion text behind U+001F, or the text is empty and the suggestion comes back as the payload. With assertions on, lookup throws AssertionError. store() writes the state to disk, so load() brings it back.
  • Ordinary triggers. An IOException from the input (BytesRefIterator.next() declares it; for example a truncated file), or the suggester rejecting one bad entry (weight -1, U+001F in a payload-enabled text, a form over 32765 bytes). Users do hit build failures: #7074 (LUCENE-6012, open) reports build() failing on records over 32767 bytes.
  • Default vs opt-in. Default constructors and settings, but one of the two builds must use the optional payload feature; both classes are @lucene.experimental. A suggester that never uses payloads cannot hit it.
  • Scale. None needed: one entry per build, deterministic.
  • Not affected. Rebuilds that keep the payload setting (the control passes), and every successful build, which repairs the state.
  • How likely. Rare: the format change and the failure (or a concurrent lookup) must coincide, and the searches found no report since 4.3.0. A configuration change that reloads into a new instance avoids it.

Minimal repro

The test shows that AnalyzingSuggester.build() stores the new input's payload flag before it can fail, so after a rebuild without payloads that throws an IOException, the previous FST (built with payloads) is decoded in the wrong format.

TestAnalyzingSuggesterFailedRebuild.java (JUnit test)48 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.analyzing;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.search.suggest.FileDictionary;
import org.apache.lucene.search.suggest.InputIterator;
import org.apache.lucene.search.suggest.Lookup.LookupResult;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestAnalyzingSuggesterFailedRebuild extends LuceneTestCase {

  public void testFailedRebuildWithoutPayloadsKeepsPayload() throws Exception {
    AnalyzingSuggester suggester =
        new AnalyzingSuggester(new ByteBuffersDirectory(), "suggest", new StandardAnalyzer());
    byte[] dictionary = "foo\t10\tp1\n".getBytes(StandardCharsets.UTF_8);
    suggester.build(new FileDictionary(new ByteArrayInputStream(dictionary)).getEntryIterator());
    InputIterator failing =
        new InputIterator.InputIteratorWrapper(
            () -> {
              throw new IOException("read failure");
            });
    expectThrows(IOException.class, () -> suggester.build(failing));
    LookupResult r = suggester.lookup("fo", false, 1).get(0);
    String key = r.key.toString().replace("\u001F", "<U+001F>");
    String payload = r.payload == null ? null : r.payload.utf8ToString();
    // expected: foo with payload p1 (the previous build); actual: key foo<U+001F>p1, payload null
    assertEquals("foo payload=p1", key + " payload=" + payload);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-784/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/suggest/build/libs/lucene-suggest-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.suggest.analyzing.TestAnalyzingSuggesterFailedRebuild
Expected vs actual
Expected: lookup("fo") returns foo with payload p1 (the previous build). Actual (-ea and -da): key foo<U+001F>p1 with payload null.

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

More files, environment, full output34 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: expected:<foo[ payload=p1]> but was:<foo[<U+001F>p1 payload=null]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.suggest.analyzing.TestAnalyzingSuggesterFailedRebuild.testFailedRebuildWithoutPayloadsKeepsPayload(TestAnalyzingSuggesterFailedRebuild.java:46)

Why it looks unintended

  • The state matches neither the old nor the attempted build: the internal U+001F separator and the payload leak into the text, and assert sepIndex != -1 fires.
  • The class publishes the new FST and count only after success; LUCENE-10292 moved count there and missed this flag.
  • Upstream's rebuild test expects the original suggestions "at every stage of the slow rebuild".
The full argument388 words
  • The result is incoherent, not stale. It matches neither the previous build nor the attempted one. The previous build had payloads, where U+001F is the reserved internal separator (build() rejects it in a payload-enabled text, "this character is reserved", AnalyzingSuggester.java:465-470, and lookup rejects it in keys, :688-690), yet that separator and the payload come back inside the returned text. LookupResult documents key as "the key's text" and payload as "the key's payload (null if not present)" (Lookup.java:48-58); here the payload is returned as the text, or the text as the payload. The class's own invariant assert sepIndex != -1 (:633) fires.
  • Late publication is the design. fst and count are assigned only after compilation (:589-590). LUCENE-10292 (CHANGES 9.2.0: "Fix other FST based suggesters so that getCount() returned results consistent with lookup() during concurrent build()") moved count there (that change was about concurrent readers and says nothing about failed builds, but it shows the intended order); in that issue (#11328) the author described build() as using temporary data structures until its logic is complete and then replacing the ones lookup() uses. hasPayloads is the derived field that move missed.
  • Upstream tests the property during a rebuild. SuggestRebuildTestUtil.java:78: "at every stage of the slow rebuild, we should still be able to get our original suggestions", run for AnalyzingSuggester by TestAnalyzingSuggester#testLookupsDuringReBuild (:173-177), during a successful rebuild with no payloads in either build. No upstream test rebuilds with another payload setting or looks up after a failed build.
  • Reuse is an established pattern. Lookup#build "Builds up a new internal Lookup representation" (Lookup.java:179-182); a committer said on java-user@ (2013) that build() "fully replaces all internal suggester state every time you call it". Both are about successful rebuilds; nothing documents the state after a failed one.
  • Ordinary failure paths. FileDictionary documents both line formats and takes the payload setting from the first line, and every line the failing stream delivers is well-formed; next() declares IOException; the other triggers are entries the suggester's own validation rejects on purpose. A rejected input should not corrupt the instance the suggester already has.
  • History. The early assignment came with payload support itself (LUCENE-4820, 2013) and was later only rewritten in place (LUCENE-5260); no commit, issue or test found in the searches discusses it.
The devil's-advocate check198 words

The checkers built the strongest case for "intended": Lookup and AnalyzingSuggester are @lucene.experimental and document no failure atomicity; sibling lookups (TSTLookup, FreeTextSuggester) also change state before their fallible steps; so a maintainer could answer "after build() throws, rebuild or discard the instance". For the during-rebuild variant, committers said in 2022 (#11328 and the dev@ thread on it) that concurrent build and lookup are not guaranteed or documented.

Why it fails: the result is not stale or empty (either would be acceptable); it matches neither the previous build nor the attempted one, violates the class's own assertion and reserved-character rule, and store() persists it. The fix costs nothing and follows the order the class already uses for fst and count. Failure atomicity is not a documented contract; the case rests on the class's own invariants and its existing publication order. The concurrency objection only weakens the secondary variant; the failed-build path is single-threaded. These checkers judged it real: an independent reproducer, a cross-model intended-behaviour review, a devil's advocate and an adjudication; the cross-model review rated the intended case moderate and outweighed. The two read-only cross-model refute reviews returned a procedural "not real" (see Independent checks).

Suggested fix

Recommended A. Publish the payload flag together with the new FST (AnalyzingSuggester.build)

Keep iterator.hasPayloads() in a local and assign the field next to fst and count at the end of build(): two changed lines. Validated: the new tests pass, as do all 241 suggest-module tests and check. For concurrent readers the three fields remain separate writes; one immutable state object would close that too.

-    hasPayloads = iterator.hasPayloads();
+    // published with the new FST below, so a failed build keeps the flag matching the old FST
+    final boolean hasPayloads = iterator.hasPayloads();
 ...
       fst = FST.fromFSTReader(fstCompiler.compile(), fstCompiler.getFSTReader());
       count = newCount;
+      this.hasPayloads = hasPayloads;
All options and trade-offs4 options · 640 words

Option A was applied in a scratch worktree of main a9779ef3c193 and run through gradle with assertions on and off; options B to D were not run.

A. Publish the payload flag together with the new FST (AnalyzingSuggester.build) (validated by the checks) (recommended)

Replace the field write at AnalyzingSuggester.java:394 with a local of the same name and assign the field right after fst and count (:589-590); +3/-1 lines, one of them a comment.

The local shadows the field, so every use inside build() (:397, :439, :465, :532, :576) reads the new input's setting and nothing else changes; no helper called from build() reads the field. If reviewers dislike the shadowing, name the local newHasPayloads and update those five uses. FuzzySuggester is fixed with it.

-    hasPayloads = iterator.hasPayloads();
+    // published with the new FST below, so a failed build keeps the flag matching the old FST
+    final boolean hasPayloads = iterator.hasPayloads();
 ...
       fst = FST.fromFSTReader(fstCompiler.compile(), fstCompiler.getFSTReader());
       count = newCount;
+      this.hasPayloads = hasPayloads;

Trade-offs

  • None for single-threaded use; no API or format change.
  • Concurrent readers still get no safe publication: fst, count and hasPayloads are written separately, only count is volatile, and lookup() reads fst and hasPayloads, both plain. A lookup that races the end of build() can still see the new FST with the old flag (the Java memory model does not confine this to one instant), as getCount() and lookup() can already disagree today. What closes is the long, deterministic window: the whole input read, sort and compilation.

What the checks validated

  • The new test class with every org.apache.lucene.search.suggest.analyzing.* suite: stock 94 tests, 4 failed: the 4 new bug cases, and 1 skipped; the new control passes and none of the 89 upstream tests fails (88 pass, 1 skipped); with the fix 94 tests, 0 failed, 1 skipped, assertions on and off.
  • all of :lucene:suggest:test with the fix: 32 classes, 241 tests, 0 failed (7 skipped), assertions on and off.
  • :lucene:suggest:check -x test passes with the fix (google-java-format, ecjLint, forbiddenApis; error-prone is skipped in the default build).
  • Through the repro harness: the new test 5 of 5 pass with the finder's equivalent fix shadowed (-ea and -da). Earlier, the finder's and the reproducer's JUnits passed with it, and the exhaustive fault-injection harness (run with assertions off) reported no mixture for either suggester (only the expected temp files left by its injected delete failure).
  • The comment line was reworded after these runs to say exactly what the change keeps (the flag; a failed build can still raise maxAnalyzedPathsForOneInput). The code lines are the validated ones; fix.diff and fix-and-test.diff apply cleanly to a9779ef3c193, and google-java-format 1.37.0 leaves the patched file unchanged.

B. One immutable state object (not run)

Hold fst, count, hasPayloads and maxAnalyzedPathsForOneInput in one final object, published with a single (volatile) write at the end of build() and in load(); lookup, getCount, store and ramBytesUsed read one snapshot. In the spirit of LUCENE-4492 (#5558, "Immutable Suggester impls. should be immutable", open).

Trade-offs

Also gives concurrent readers one consistent snapshot, but a larger diff that touches every reader of the four fields; no behaviour change for single-threaded callers.

C. Reset to empty when build() throws (not recommended)

Clear fst and count if build() throws, so lookups return nothing.

Trade-offs

Coherent, but it throws away the previous suggestions, which the class keeps today when a same-setting rebuild fails (the control).

D. Document that a failed build leaves the instance unusable (not recommended)

Say in the Lookup#build javadoc that callers must discard the instance after build() throws.

Trade-offs

The state stays corrupt and store() still persists it; it leaves a trap in the established in-place rebuild pattern and goes against the class's own late publication of fst and count.

Downstream note

By code reading, Solr's suggest component logs a failed rebuild and keeps serving the same Lookup, so a deployment whose suggestion file changes its payload column and whose rebuild then fails serves these garbled suggestions, with payload bytes inside the suggestion text.

How it was found

Code reading in the hunt's unit on suggester rebuild failure atomicity flagged the flag written before the fallible work. A bounded-exhaustive fault-injection harness then failed every second build at every injected point and found the mixture for every injected failure that throws after the flag write, and only when the two builds' payload settings differ.

The method in full321 words
  • Lead (code reading). The unit plan noted that AnalyzingSuggester.java:394 writes the flag before the input loop, sort and compilation, while :589-590 publish fst and count only after them, so a mixture needs a failed rebuild whose input differs in hasPayloads().
  • Witness (semi-formal): bounded-exhaustive fault-injection translation validation on the stock jar, over five Lookups: a first build from every dictionary of up to 2 entries and a second build from every dictionary of up to 3 entries over a 4-term alphabet (with a 4-byte and a 3-byte UTF-8 letter), with and without payloads, the second build failing at every injected point: an input IOException or RuntimeException after k entries, an analyzer exception, temp-directory create/open/delete failures, an input with contexts, and invalid entries (weight -1 or 2^31, U+001F in a payload-enabled text, a 40,000-character term, a 40,000-byte payload).
  • Oracle. Afterwards the lookups and getCount must match a fresh instance built from the previous input, a never-built instance, or, only if the second build did not throw, a fresh build of the new input; store()+load() must then match the same reference; and a third, successful build from the previous input must match a fresh build of it.
  • Signal. 7,220 of 19,470 checks each for AnalyzingSuggester and FuzzySuggester showed the mixture, with the same totals with assertions on and off (for FuzzySuggester with assertions on only the summary totals were kept), for every injected failure that throws after the flag write (all kinds except the contexts input, which is rejected before line 394; the U+001F-surface and long-payload entries are not invalid without payloads), and only when the payload settings differ. With fix A (run with assertions off) none: the 660 remaining reports are the expected temp files left by the injected delete failure. On stock no third build differed from a fresh one, so a successful build repairs the state. The finder then wrote the public-API JUnit and the fix, and independent checkers followed.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-27: 293 searches over GitHub, JIRA, the Lucene lists, downstream projects and the web, plus all 409 open PRs). Closest: LUCENE-10292, which moved count next to the FST in the same method but never mentions the payload flag.

Searches3 searches

Plausible hits were read; none describes this bug. Keyword search only: a report that only describes the symptom could be missed;

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (four runs)2026-10-27222 queries over apache/lucene issues, PRs and commits, three downstream search projects, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web: class names, hasPayloads, the error texts and the assertion, rebuild and concurrency phrasingsno report
Manual searches and reads2026-10-2726 GitHub, 12 JIRA, 23 mailing-list and 9 web searches; 36 direct reads of issues, threads and downstream copies; 1 scan of the changed files of all 409 open apache/lucene PRsno report; no retrieved open-PR file list touches AnalyzingSuggester.java or FuzzySuggester.java (#16911's list was only partly fetched: 3,000 of 7,630 files)
Closest items (read)2026-10-27full threads#11328 / LUCENE-10292 (closed, 9.2.0: lookups and getCount during a rebuild; moved count, never mentions hasPayloads or a failing build); #5558 / LUCENE-4492 (open: make suggesters immutable, no wrong result); #7074 / LUCENE-6012 (open: build() fails on records over 32767 bytes, state afterwards not discussed); #5885, #6324, #6401, #6184, #6722

History

Present since payloads were added to the analyzing suggesters by LUCENE-4820 (234fa47dba4, 2013-03-13), first released in 4.3.0. The bug shows on all 126 releases from 4.3.0 to 10.5.2 and on main. Not fixed upstream.

Versions tested5 rows

Introduced by LUCENE-4820 (now #5885), "add optional payload to Analyzing/FuzzySuggester", committed by @mikemccand: trunk 234fa47dba4 (2013-03-13) and its branch_4x copy d11bc054c4e the same day (Subversion era, no PR). It added the field and assigned it at the top of build() (hasPayloads = payloads != null;), while the FST was published at the end.

Not a regression. LUCENE-5260 (trunk c1be5650a9b; in 4.6.0 through branch_4x 1d57d7f5f9c) rewrote the line in place as hasPayloads = iterator.hasPayloads();; LUCENE-10292 (main 5015dc6dbb8; in 9.2.0 through branch_9x b11b0714297) moved count = newCount next to the FST and left the flag. On 4.3.0 and 4.3.1 the in-memory symptom is a lost payload instead of glued text (an older decode bug, LUCENE-5120, fixed in 4.4.0); after store+load they match later releases.

Not fixed upstream. Upstream main f7bcc0abd816 (2026-10-27), branch_10x and branch_10_6 still assign the flag at :394; no retrieved open-PR file list touches the files (one very large PR, #16911, was only partly listed).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectednew test 4 of 5 fail through gradle and the repro harness (assertions on and off); the control passes
10.5.2, 9.12.3 affectednew test 4 of 5 fail; the finder's and the reproducer's JUnits fail
4.3.0 ... 10.5.2 (all 126 releases) affectedpublic-API probe with assertions on: 4 of 4 bug cases wrong, 0 of 5 controls fail (3 checks after the first build, 2 same-setting failed rebuilds); also with assertions off on 4.3.0, 4.6.0, 10.5.2 and main
4.1.0, 4.2.0, 4.2.1 API absentno payload API; the same-format control passes
fix A on main a9779ef3c193 not affectedthe new test 5 of 5 and all 241 suggest-module tests pass (assertions on and off); check -x test passes

Independent checks

An independent reproducer (own 7-test suite), a cross-model intended-behaviour review, an intended-behaviour devil's advocate, an adjudication and a credibility audit all judged it real, and the executing ones re-ran it. Two read-only cross-model refute reviews said "not real" only because they could not run code.

Checks and verdicts462 words

Every executed check ran on main a9779ef3c193 (and some on 10.5.2 and 9.12.3). The cross-model reviews ran, reading only; a separate run re-ran the repro after each (fails). This doc's own gradle validation also ran on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
Cross-model refute reviews (2 runs) not real (low), proceduralAn independent reviewer from another model family (OpenAI GPT-5.6), reading only (its checkout was main ea57360912ed, whose suggest sources equal a9779ef3c193): it votes not real without a run of its own. Run 2: "Static evidence strongly favors a genuine defect ... public APIs, permitted IOException behavior, ordinary weights, and tiny inputs"; root cause agreed. Run 1's objection to a malformed-file trigger was taken (the trigger was replaced), and its note that the fix is not an atomic publication is stated. A separate run re-ran the repro both times: 4 of 4 fail.
Independent reproducer realOwn public-API suite written from the claim: 5 of 7 fail on main and 10.5.2 (assertions on and off) and on 9.12.3 (assertions on); both same-setting controls pass; the during-rebuild case fails 10 of 10; its own fix (the same change) passes 7 of 7.
Cross-model intended-behaviour review real (medium)The same OpenAI GPT-5.6 reviewer with an intended-behaviour lens, reading only: the "experimental, no failure atomicity" case is moderate but outweighed by the declared IOException, LookupResult's documented meaning, the upstream rebuild test and LUCENE-10292. A separate control test failed 1 of 3 on main and 10.5.2 (both controls pass) and passed 3 of 3 with the fix.
Intended-behaviour devil's advocate realBuilt the strongest "intended" case (no documented failure atomicity or concurrency) and rejected it for the failed-build path; the during-rebuild variant is secondary. Re-ran the repro on main (assertions on and off), 10.5.2 and 9.12.3: 4 of 4 fail.
Adjudication confirmed (high), priority LowRe-ran the reproducer's suite with a new seed: 5 of 7 fail with assertions on and off, 7 of 7 pass with the fix. The fix is not an atomic publication.
Credibility audit real; impact score loweredRe-ran the finder's JUnit with fresh seeds (4 of 4 fail, assertions on and off; 4 of 4 pass with the fix) and re-read the code; lowered the impact score because payloads are an opt-in feature of an experimental API. The bug itself stands.
This doc's fix validation fix A validatedScratch worktree of a9779ef3c193, gradle, seed DEADBEEF: stock, the 94 suggest.analyzing.* tests have 4 failures, exactly the new bug cases; with the fix 94 tests with 0 failed (1 skipped) and all 241 suggest-module tests with 0 failed (7 skipped), assertions on and off, and :lucene:suggest:check -x test passes. The worktree was removed afterwards.

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

Finding record

LFV-784, snapshot of 2026-11-06

Registry title
AnalyzingSuggester and FuzzySuggester take the new input's payload flag before build() can fail, so after a failed rebuild (or during a rebuild) lookups decode the previous FST in the wrong payload format: the payload glued to the suggestion text, or the suggestion returned as the payload
Area
suggest / analyzing suggester (AnalyzingSuggester.build failure atomicity: hasPayloads is published before the new FST; inherited by FuzzySuggester)
Priority
Low, rank 493: The experimental analyzing and fuzzy suggesters misdecode a stale FST only after a failed rebuild that also changes the payload setting, which is rarer than the Medium LFV-646, so it goes with its sibling LFV-601.
Impact class
silent wrong results
Impact note
Applications that rebuild one AnalyzingSuggester or FuzzySuggester instance (default constructors and settings; every release from 4.3.0 through 10.5.2, 10.6.0 (by reading) and main) from an input whose payload setting differs from the previous build's (a FileDictionary whose first line gained or lost the payload column, or a DocumentDictionary whose payloadField was added or removed) and keep serving that instance after the rebuild throws (an IOException from the input, or the suggester rejecting an entry), or look it up while that rebuild is still reading its input, get every suggestion decoded in the wrong format until the next successful build: the text with U+001F and the payload appended and a null payload, or empty text with the suggestion returned as the payload (an AssertionError with -ea); store() persists that state, no scale is needed, and rebuilds that keep the payload setting are unaffected.
Summary
You build an AnalyzingSuggester (or FuzzySuggester) from input with payloads, for example 'foo' with weight 10 and payload 'p1', then rebuild the same instance from a dictionary without payloads whose input throws an IOException partway through, and you expect lookups to keep returning the previous build ('foo', payload 'p1') or nothing. Instead lookup('fo') returns the text 'foo<U+001F>p1' with payload null, because build() stores the new input's payload flag at its start (AnalyzingSuggester.java:394) but replaces the FST only at its end (:589-590), so the old FST is read with the new flag; in the opposite direction (old build without payloads, failed rebuild with payloads) lookups return empty text with 'foo' as the payload, or trip the suggester's own assertion with -ea. A lookup made while such a rebuild is still reading its input sees the same mixture, and store() writes it to disk, so load() brings it back.
Introduced
234fa47dba438452a9952f748208ce8da6eea252 (LUCENE-4820 'add optional payload to Analyzing/FuzzySuggester', by GitHub mikemccand; trunk svn r1456095, 2013-03-13; branch_4x backport d11bc054c4e2f7239ddc74db3aa88722708af79f, svn r1456098, the same day; Subversion era, no pull request; issue now apache/lucene#5885). It added the hasPayloads field, assigned at the top of build() ('hasPayloads = payloads != null;', before the input loop, the offline sort and FST compilation), while the new FST is published only at the end [read: git log -L on today's assignment line AnalyzingSuggester.java:394 in the full-history clone ends at this commit; git blame gives c1be5650a9b (LUCENE-5260, apache/lucene#6324, 2013-10-13), which only rewrote it in place as 'hasPayloads = iterator.hasPayloads();'; LUCENE-10292 (5015dc6dbb8, 2022-04-08, 9.2.0) moved count next to the FST and left the flag where it was] [exec: 4.3.0, the first release that contains the commit, fails; 4.2.1 has no payload API] (2013-03-13)
First affected version
4.3.0 [exec: the follow-up's public-API probe gets 4 of 4 bug cases wrong with -ea and -da while all 5 controls pass] (released 2013-05-06; the first release with suggester payloads, CHANGES 4.3.0 'LUCENE-4820: Add payloads to Analyzing/FuzzySuggester'); 4.1.0, 4.2.0 and 4.2.1 have no payload API (the probe does not compile on 4.2.1; their same-format control passes) [exec]
Affected versions
every release from 4.3.0 through 10.5.2, 10.6.0 (by reading) and main a9779ef3c193; not fixed upstream [exec: the follow-up's probe sweep gets 4 of 4 bug cases wrong, with 0 of 5 controls failing, on all 126 lucene-suggest releases from 4.3.0 to 10.5.2 on Maven Central (-ea), with -da also on 4.3.0, 4.6.0 and 10.5.2, and on main with -ea and -da; the finder's JUnit fails 4 of 4 on main (-ea and -da), 10.5.2, 9.12.3 (-ea and -da), and in an 8.x-import copy on 8.11.4 and 7.7.3; the independent reproduction's JUnit fails 5 of 7 on main (-ea and -da), 10.5.2 (-ea and -da) and 9.12.3; a separate control test fails 1 of 3 on main and 10.5.2 (-da)] [read: all 117 release tags from 4.3.0 to 10.5.2 in the history clone assign the flag before the FST in build(); upstream main f7bcc0abd816 (2026-10-27), branch_10x and branch_10_6 are unchanged]. On 4.3.0 and 4.3.1 the in-memory symptom of the payloads-then-no-payloads case is 'foo' with the payload lost (the payload-mode decode then truncated the FST's cached output in place, fixed by LUCENE-5120 in 4.4.0); after store()+load() they show the same 'foo<U+001F>p1' as every later release.
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 low-confidence “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: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record verdict new, confidence high for 'not reported': 293 recorded searches, namely 222 from the scripted prior-report search over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web, plus 26 manual GitHub, 12 JIRA, 23 mailing-list and 9 web searches and 36 direct reads of issues, threads and downstream copies; the changed-file lists of all 409 open apache/lucene PRs (one very large PR only partly listed): none touches AnalyzingSuggester.java or FuzzySuggester.java; the intended-behaviour check's prior-art run agrees). Closest items, none the same bug: #11328 / LUCENE-10292 (closed, 9.2.0: lookups and getCount during a concurrent rebuild; it moved AnalyzingSuggester's count publication next to the FST but never mentions hasPayloads or a failing build, and its test builds twice without payloads); #5558 / LUCENE-4492 (open since 2012: suggesters should be immutable; a design issue that reports no wrong result); #7074 / LUCENE-6012 (open: build() throws on records over 32767 bytes, one of the failures that can trigger this; the thread never discusses the state left behind); #6722 / LUCENE-5660 (build() throws IAE on too-long input by design); #5885 / LUCENE-4820 (the introducing change), #6324 / LUCENE-5260, #6401 / LUCENE-5337 (FileDictionary's first-line payload rule) and #6184 / LUCENE-5120 (a different payload-decode bug, 4.4.0). Upstream main f7bcc0abd816 (2026-10-27) still assigns the flag at AnalyzingSuggester.java:394.
Lucene commit tested
main a9779ef3c193 (2026-10-26) through gradle in a scratch worktree (removed afterwards): the new 5-test class with all org.apache.lucene.search.suggest.analyzing.* suites, assertions on and off, stock and with the fix; with the fix also all of :lucene:suggest:test (assertions on and off) and :lucene:suggest:check -x test. The new test through the repro harness on main (-ea, -da, and with the fix shadowed), 10.5.2 (-ea, -da) and 9.12.3; the follow-up's public-API probe on all 126 releases from 4.3.0 to 10.5.2 and on main. A fact check re-ran the new test with fresh seeds through gradle on main (assertions on and off) and through the repro harness on main, 10.5.2 and 9.12.3 (assertions off for 9.12.3). OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:98 (at a9779ef3c193) (@lucene.experimental), :394 (hasPayloads = iterator.hasPayloads(), before the input loop, the offline sort and FST compilation), :466-469 (U+001F rejected in a payload-enabled surface form), :589-590 (fst and count published only after FST compilation), :600-609 (store() writes count, the FST and, at :608, the flag), :613-619 (load() reads the flag), :623-646 (getLookupResult decodes the FST output by hasPayloads; assert sepIndex != -1 at :633), :912-914 (encodeWeight rejects weights outside [0, Integer.MAX_VALUE])
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:64 (at a9779ef3c193) (@lucene.experimental), :66 (FuzzySuggester extends AnalyzingSuggester; build() inherited)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:38 (at a9779ef3c193) (@lucene.experimental), :48-58 (LookupResult: 'the key's text', 'the key's payload (null if not present)'), :179-182 (build: 'Builds up a new internal Lookup representation')
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/FileDictionary.java:47 (at a9779ef3c193) ('In order to have payload enabled, the first entry has to have a payload')
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/DocumentDictionary.java:105 (at a9779ef3c193) (payloads enabled iff a payloadField is configured)
  • lucene/core/src/java/org/apache/lucene/util/BytesRefIterator.java:32-34 (at a9779ef3c193) (next() throws IOException; InputIterator extends it)
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/SuggestRebuildTestUtil.java:78 (at a9779ef3c193) ('at every stage of the slow rebuild, we should still be able to get our original suggestions'); TestAnalyzingSuggester.java:173-177 (testLookupsDuringReBuild runs it for AnalyzingSuggester)
  • lucene/CHANGES.txt:4272-4274 (at a9779ef3c193) (LUCENE-10292, 9.2.0 bug fix: getCount() consistent with lookup() during concurrent build())
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:161 (PAYLOAD_SEP), :390-394, :415-485, :424, :441, :465-470, :484, :490, :576-587, :589-590, :600-609, :613-620, :623-648, :633, :638, :650-665, :786, :913-914 @ a9779ef3c193
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:66; lucene/suggest/src/java/org/apache/lucene/search/suggest/FileDictionary.java:47; lucene/suggest/src/java/org/apache/lucene/search/suggest/DocumentDictionary.java:105 @ a9779ef3c193
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:98 (@lucene.experimental); lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:64; lucene/core/src/java/org/apache/lucene/util/BytesRefIterator.java:32-34 @ a9779ef3c193
Why it looks unintended
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:465-470, :589-590, :633, :688-690 @ a9779ef3c193
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:38, :48-58, :179-182; lucene/suggest/src/java/org/apache/lucene/search/suggest/FileDictionary.java:47 @ a9779ef3c193
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/SuggestRebuildTestUtil.java:78; lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestAnalyzingSuggester.java:173-177 @ a9779ef3c193
  • lucene/CHANGES.txt:4272-4274 @ a9779ef3c193 (LUCENE-10292)
Suggested fix
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:157-159, :394, :397, :439, :465, :532, :576, :589-590 @ a9779ef3c193
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:106, :159, :167 (only count is volatile), :484, :618, :786 @ a9779ef3c193

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

Repro files

About the diagrams

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