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.
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.- You build an
AnalyzingSuggesterfrom a suggestion file with one linefoo<TAB>10<TAB>p1(text, weight, payload).lookup("fo")returnsfoo, weight 10, payloadp1. - You rebuild the same instance from a new file without the payload column,
bar<TAB>3, and reading it fails after that line:build()throwsIOException. You keep serving the instance and expect the old answer, or none. - Instead
lookup("fo")returns the textfoo<U+001F>p1with payloadnull: the payload glued to the text behind a reserved separator.store()andload()keep it. - The other direction (old build without payloads, failed rebuild with payloads) returns empty text with
fooas the payload, or throwsAssertionErrorfromlookupwith assertions on. - 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.
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.
- AnalyzingSuggester / FuzzySuggester: FST-based suggesters in the suggest module (
FuzzySuggesterextendsAnalyzingSuggesterand inheritsbuild()).build(InputIterator)(orbuild(Dictionary)) builds the suggestions;lookup(key, ...)returnsLookupResults with a text (key), a weight (value) and an optionalpayload;store()/load()save and restore the built state. - Payloads: optional bytes per suggestion. An input says whether it has them with
hasPayloads().FileDictionarydecides from the first line of the file ("In order to have payload enabled, the first entry has to have a payload");DocumentDictionaryfrom whether apayloadFieldis 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 andlookuprejects it in keys.
- Right after its contexts check (
AnalyzingSuggester.java:390-392),build()assigns the field:hasPayloads = iterator.hasPayloads();(:394). - Then it reads the input (
:415-485:next()may throwIOException; the analysis may throw; an entry is rejected withIllegalArgumentExceptionfor U+001F with payloads on (:465-470), an over-long analyzed form (:424) or, with payloads on, an over-long surface form (:441), or withUnsupportedOperationExceptionfor a weight outside[0, Integer.MAX_VALUE](:913-914)), sorts it through the temp directory (:490) and compiles the FST. - Only then are the new FST and count published:
fst = ...; count = newCount;(:589-590). If anything above throws,fstandcountstill describe the previous build. getLookupResult(:623-648) splits each output at U+001F onlyif (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) andload()reads it back (:619).- Result: flag newly
false: the old outputfoo<U+001F>p1is returned whole as the text, payloadnull. Flag newlytrue: the old outputfoohas no separator; with assertions onlookupthrowsAssertionError, with them off the text is empty andfoois returned as the payload (the payload is copied from the start of the output's bytes,:638). - 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. - A failed build can also raise
maxAnalyzedPathsForOneInput(:484), as every build does (it is never reset inbuild()); it only sizes the lookup's top-N search queue (:786) and plays no part in decoding.
| suggester | previous build | rebuild that throws | lookup afterwards | expected |
|---|---|---|---|---|
| Analyzing | foo 10 p1 | bar 3 (no payloads), IOException after line 1 | foo<U+001F>p1, weight 10, payload null (also after store+load) | foo, 10, p1 |
| Analyzing | foo 10 (no payloads) | bar 3 q, b<U+001F>z 2 r: IllegalArgumentException | AssertionError (-ea); empty text, 10, payload foo (-da) | foo, 10, null |
| Fuzzy | foobar 10 p1 | bar 3, baz -1 (no payloads): UnsupportedOperationException | foobar<U+001F>p1, 10, null | foobar, 10, p1 |
| Analyzing | foo 10 p1 | bar 3 q (payloads too), IOException (control) | foo, 10, p1 | same |
| Analyzing | foo 10 p1 | none: lookup while bar 3 is still being read | foo<U+001F>p1, 10, null | foo, 10, p1 |
Exposure and likelihood281 words
- Who is exposed. Applications that keep one
AnalyzingSuggesterorFuzzySuggesterand rebuild it in place (an established pattern for successful rebuilds: a committer wrote on java-user@ in 2013 thatbuild()"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,
lookupthrowsAssertionError.store()writes the state to disk, soload()brings it back. - Ordinary triggers. An
IOExceptionfrom 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) reportsbuild()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.
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.
/*
* 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);
}
}
# 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.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- 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 != -1fires. - The class publishes the new FST and count only after success; LUCENE-10292 moved
countthere 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, andlookuprejects it in keys,:688-690), yet that separator and the payload come back inside the returned text.LookupResultdocumentskeyas "the key's text" andpayloadas "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 invariantassert sepIndex != -1(:633) fires. - Late publication is the design.
fstandcountare 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()") movedcountthere (that change was about concurrent readers and says nothing about failed builds, but it shows the intended order); in that issue (#11328) the author describedbuild()as using temporary data structures until its logic is complete and then replacing the oneslookup()uses.hasPayloadsis 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 forAnalyzingSuggesterbyTestAnalyzingSuggester#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) thatbuild()"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.
FileDictionarydocuments both line formats and takes the payload setting from the first line, and every line the failing stream delivers is well-formed;next()declaresIOException; 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).
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,countandhasPayloadsare written separately, onlycountis volatile, andlookup()readsfstandhasPayloads, both plain. A lookup that races the end ofbuild()can still see the new FST with the old flag (the Java memory model does not confine this to one instant), asgetCount()andlookup()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:testwith the fix: 32 classes, 241 tests, 0 failed (7 skipped), assertions on and off. :lucene:suggest:check -x testpasses 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 (
-eaand-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.diffandfix-and-test.diffapply cleanly toa9779ef3c193, and google-java-format 1.37.0 leaves the patched file unchanged.
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.
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).
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.
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.
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:394writes the flag before the input loop, sort and compilation, while:589-590publishfstandcountonly after them, so a mixture needs a failed rebuild whose input differs inhasPayloads(). - 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
IOExceptionorRuntimeExceptionafter 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
getCountmust 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
AnalyzingSuggesterandFuzzySuggestershowed the mixture, with the same totals with assertions on and off (forFuzzySuggesterwith 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.
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;
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (four runs) | 2026-10-27 | 222 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 phrasings | no report |
| Manual searches and reads | 2026-10-27 | 26 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 PRs | no 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-27 | full 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 |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | new test 4 of 5 fail through gradle and the repro harness (assertions on and off); the control passes |
| 10.5.2, 9.12.3 | affected | new test 4 of 5 fail; the finder's and the reproducer's JUnits fail |
| 4.3.0 ... 10.5.2 (all 126 releases) | affected | public-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 absent | no payload API; the same-format control passes |
fix A on main a9779ef3c193 | not affected | the new test 5 of 5 and all 241 suggest-module tests pass (assertions on and off); check -x test passes |
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.
| Check | Verdict | What it found |
|---|---|---|
| Cross-model refute reviews (2 runs) | not real (low), procedural | An 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 | real | Own 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 | real | Built 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 Low | Re-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 lowered | Re-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 validated | Scratch 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 467 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,552 words
- 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 allorg.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.
- 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 @ a9779ef3c193lucene/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 @ a9779ef3c193lucene/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 @ a9779ef3c193lucene/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 @ a9779ef3c193lucene/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 @ a9779ef3c193lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:106, :159, :167 (only count is volatile), :484, :618, :786 @ a9779ef3c193
- TestAnalyzingSuggesterFailedRebuild.java (JUnit test):
repro/LFV-784/src/org/apache/lucene/search/suggest/analyzing/TestAnalyzingSuggesterFailedRebuild.java(48 lines)
assets/LFV-784/bug.png(467 KB)
