Only the re-saved copy is wrong. The original file, freshly built FSTs, and BYTE1 or BYTE4 FSTs keep every key; BYTE1 and BYTE4 copies are only labelled 9.
a. Select the still to open it at full size. Generated with AI, checked against the code.- Take a char-keyed (
INPUT_TYPE.BYTE2) FST that Lucene 8.11.4 saved: 9 keys such asab→ 1,cat→ 5 and日本→ 8 (FST format 7). - Load it on main (or any release since 9.0.0):
getVersion()is 7 and all 9 keys are found. Save it withfst.save(...)and load the copy. You expect the same 9 keys. - You get 0 of 9 keys and no exception. The copy is the original byte for byte except one: the header version, 7 → 9.
- Why: 8.x stored 2-byte labels in the old byte order, and the reader swaps them back only when the header says a version below 8.
savecopies the bytes but always writes the current version (9; 8 in 9.0-9.8), soa(0x0061) is read as 0x6100愀:get("ab")is null,get("愀戀")is 1. - Same line, smaller symptom: an FST built with
setVersion(FST.VERSION_90)is saved as version 9, not 8. Lucene 8.11.4 re-saves the old file correctly (header 7, 9 of 9 keys).
Applications that keep char-keyed (BYTE2) FSTs in files written by Lucene 7.0.0 to 8.11.4 and save them again on 9.0 or later (copy, migrate, cache). The copy silently answers wrongly (0 of 9 keys in the repro). No setting is involved, but no Lucene code path does this: suggesters use BYTE1, and the bundled dictionaries are regenerated from source.
Mechanism, terms and numbers713 words
FST.FSTMetadata#save writes the constant VERSION_CURRENT (9) into the FST header, whatever version the FST it saves is in, while FST#save copies the FST bytes of an on-heap FST (what FST.read and the FST(FSTMetadata, DataInput) constructor create) unchanged. Lucene 9.0+ still reads FST formats 6 and 7 (written by Lucene 7.0.0-8.3.1 and 8.4.0-8.11.4; older formats are rejected when loaded), and for BYTE2 labels the decoding depends on the header version: before format 8 the two label bytes are in the old order, and readLabel swaps them back only if the header says less than 8. So a char-keyed FST written by Lucene 8.11.4, loaded and saved again on 9.0 or later, comes back with header 9 (8 on 9.0-9.8) over format-7 bytes: every label is read byte-swapped and the lookups go wrong, with no error (in the repro 9 of 9 keys before, 0 of 9 after). The copy differs from the original only in the version byte. Every release from 9.0.0 to 10.5.2 and main have it; 8.11.4 re-saves the same file correctly. The same line also labels an FST built with setVersion(FST.VERSION_90) as version 9 instead of 8.
- FST: Lucene's compact map from a sequence of labels to an output (terms index, suggesters, dictionaries). It can be written to a file with
FST#saveand read back withFST.readMetadataplus theFSTconstructor, orFST.read(Path). - BYTE2: the input type whose labels are 2-byte values, used for char-keyed FSTs built over UTF-16 code units (
Util.toUTF16). BYTE1 labels are single bytes, BYTE4 labels are vInts. - Header version: the FST format version in the file header (
CodecUtilheader, a big-endian int at bytes 8-11). Lucene 7.x and 8.0-8.3 wrote 6, 8.4-8.11 wrote 7, 9.0-9.8 wrote 8, 9.9 and later write 9.
- Lucene 8.x wrote BYTE2 labels with the big-endian
writeShort. LUCENE-9047 madeDataOutputlittle-endian in 9.0 (MIGRATE.md:1179-1183), andFSTCompiler.writeLabelwrites BYTE2 labels without.writeShortsince (FSTCompiler.java:585-587). readMetadataaccepts formats 6 to 9 (:457-459) and keeps the file's version (:493).readLabelswaps BYTE2 labels when that version is below 8:if (metadata.version < VERSION_LITTLE_ENDIAN) v = Short.reverseBytes(in.readShort()) & 0xFFFF;(:553-554). It is the only version-dependent read.FST#savewrites the metadata, then the FST bytes (:524-527): for an on-heap FST, whichFST.readand theFST(FSTMetadata, DataInput)constructor create (:419-420,:538-543), the loaded bytes unchanged (OnHeapFSTStore.java:84-90; an off-heap FST cannot be saved,OffHeapFSTStore.java:64-66).FSTMetadata#savewritesCodecUtil.writeHeader(metaOut, FILE_FORMAT_NAME, VERSION_CURRENT)(:1214), ignoring its ownversionfield.- Reloading the copy, the header says 9,
readLabelno longer swaps, and every BYTE2 label is read with its bytes reversed.Util.getreturns null for all 9 keys of the repro; nothing throws.
The 8.11.4 fixture (16 metadata bytes, 64 FST bytes, 9 keys): the original finds 9 of 9 keys with getVersion() = 7. After save the metadata differs in one byte (offset 11, 07 → 09) and the FST bytes are identical; the copy finds 0 of 9 keys with getVersion() = 9, get("ab") = null and get("愀戀") = 1, the byte-swapped key. Its enumeration lists 愀戀 → 1 ... 挀愀琀 → 5. With BYTE1 or BYTE4 labels the same steps keep every key. The 0 of 9 (and 0 of 45 in the history probe) holds for these fixtures, not for every key of every BYTE2 FST: a key made only of UTF-16 units whose two bytes are equal survives (an executed check: an 8.11.4 FST {a → 1, U+6161 → 2} re-saved on main gives get("a") = null and get("慡") = 2), the empty input keeps its output (stored in the metadata, its lookup reads no label: FST.java:593-596, Util.java:47-65), and byte-swapped keys can resolve instead.
FSTCompiler.Builder#setVersion (FSTCompiler.java:335-348) accepts VERSION_90 (8) to VERSION_CURRENT; the compiler honours it (no continuous-arc nodes below 9, :543) and the in-memory version is 8, but save writes 9. After readMetadata, getVersion() returns 9, and a Lucene 9.8 reader rejects the file with IndexFormatTooNewException, although its content is format 8: with only the header int patched to 8, 9.8.0 reads a 50,000-term FST with 0 wrong lookups.
Exposure and likelihood354 words
- Who is exposed. Applications that persist standalone FSTs (
FST.save(Path)/FST.read(Path), orsave(DataOutput, DataOutput)) with BYTE2 labels, in format 6 or 7 (written by Lucene 7.0.0 to 8.11.4), and that load such a file on 9.0 or later and save it again: a copy to another place, a migration to a new store, a cache that writes the loaded FST back. The original file keeps working; the copy is silently wrong on every 9.0+ reader, and 8.x readers reject header 8 or 9. - No in-tree path. The suggesters that store and load FSTs (
AnalyzingSuggester,FSTCompletionLookup,WFSTCompletionLookup,FreeTextSuggester,NRTSuggesterBuilder) build them with BYTE1, so a re-stored old suggester is only mislabelled. The in-tree BYTE2 FSTs (Kuromoji and Nori dictionaries and user dictionaries,NormalizeCharMap, HunspellConvTable) are built fresh: the bundled dictionary FSTs are regenerated from source (#12911, #12924), the user dictionaries from the user's text. - Default, but rare. No setting is needed; the trigger is the data plus the re-save. How many applications keep pre-9.0 BYTE2 FST files and re-save them is unknown; no downstream report was found. FST and FSTCompiler are
@lucene.experimental. - Scale. Every key of the test fixtures is lost: 0 of 9 in the repro, 0 of 45 in the history probe on every release from 9.0.0 to 10.5.2 and main, for files written by 7.7.3 (format 6) and 8.11.4 (format 7). Not a rule for every BYTE2 FST: keys made only of UTF-16 units with two equal bytes (such as U+6161, executed) and the empty input can survive, and byte-swapped keys can resolve instead (
get("愀戀")= 1 in the repro). - Recoverable. The copy differs from the original only in the version byte, so restoring it gives back the original file.
- Second symptom. Needs the expert
setVersion(VERSION_90); the only in-tree caller is the backward-codecs test writerLucene90BlockTreeTermsWriter. Apart from the wrong reported version (getVersion()= 9 afterreadMetadata, on current readers too), its only demonstrated compatibility failure is rejection by readers capped at version 8 (9.0 to 9.8), which Lucene does not promise to support.
The test shows that an FST saved by Lucene 8.11.4 (FST version 7, BYTE2 labels, one key "a" -> 1) loads correctly on main, but after FST#save and reloading, the copy no longer finds "a": FSTMetadata#save writes the header version VERSION_CURRENT, so the copied big-endian BYTE2 labels are read little-endian.
/*
* 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.util.fst;
import java.io.IOException;
import java.util.HexFormat;
import org.apache.lucene.store.ByteArrayDataInput;
import org.apache.lucene.store.ByteBuffersDataOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.IntsRefBuilder;
public class TestResavePre90Byte2Fst extends LuceneTestCase {
// Saved by Lucene 8.11.4 (FST version 7): INPUT_TYPE.BYTE2, PositiveIntOutputs, "a" -> 1.
private static final byte[] META = HexFormat.of().parseHex("3fd76c17034653540000000700010405");
private static final byte[] DATA = HexFormat.of().parseHex("000161001f");
private static FST<Long> load(byte[] meta, byte[] data) throws IOException {
return new FST<>(
FST.readMetadata(new ByteArrayDataInput(meta), PositiveIntOutputs.getSingleton()),
new ByteArrayDataInput(data));
}
public void testResaveKeepsLookups() throws Exception {
ByteBuffersDataOutput meta = new ByteBuffersDataOutput();
ByteBuffersDataOutput data = new ByteBuffersDataOutput();
load(META, DATA).save(meta, data);
FST<Long> copy = load(meta.toArrayCopy(), data.toArrayCopy());
// Expected 1 as in the loaded FST; actual null (copy is labelled VERSION_CURRENT: labels swap).
assertEquals(Long.valueOf(1), Util.get(copy, Util.toUTF16("a", new IntsRefBuilder())));
}
}
# in a copy of this finding's repro folder (repro/LFV-052/, 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: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.util.fst.TestResavePre90Byte2Fst
- Expected vs actual
- Expected
Util.get(copy, "a") == 1as in the loaded FST; actualnull(expected:<1> but was:<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
=> java.lang.AssertionError: expected:<1> but was:<null>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.util.fst.TestResavePre90Byte2Fst.testResaveKeepsLookups(TestResavePre90Byte2Fst.java:44)- Main deliberately reads format 6 and 7 FSTs and decodes their BYTE2 labels correctly (
FST.java:553). getVersion()is "the version constant of the binary format this FST was written in";savecopies those bytes but writes 9.- The version exists so readers fail with "an understandable error" (#12748). Lucene 8.11.4 re-saves the file correctly.
The full argument393 words
- Reading old FSTs is offered on purpose.
VERSION_START= 6 is "the version that was used when releasing Lucene 7.0" (FST.java:113-114);readMetadataaccepts 6 to 9 ("we don't have back-compat promise for FSTs (they are experimental), but we are sometimes able to offer it",:457-459), andreadLabelhas a branch written for exactly these old BYTE2 files (:553). Main decodes the 8.11.4 file correctly: 9 of 9 keys. - The documented meaning of the version.
FSTMetadata#getVersion: "Returns the version constant of the binary format this FST was written in" (:1191-1195).save+readMetadataturns 7 into 9 (and 8 into 9) while the bytes stay as they were. Kuromoji's and Nori'sTestTokenInfoDictionary.testBinaryFSTIsLatestFormat(#12911) read the bundled FST's header withFST.readMetadata(...).getVersion()to check that its format is current: Lucene's own tests use the header version to tell what format the bytes are in. savehas no precondition. "Save the FST to DataOutput" (:518-523) and "Writes an automaton to a file" (:529); theFSTCompilerjavadoc lists "save it to other DataOutput, and load it later and use it" as a use (FSTCompiler.java:61-62).TestFSTs.testSaveDifferentMetaOut(:1204-1243) asserts that load, save and reload keep the lookups, for a current-format FST. No test re-saves an older-format FST or checks the header thatsavewrites for one.- Design intent. mikemccand on the version bump in #12748 (2023-11-06): if the version is bumped, an older reader "would throw an understandable error". Here a newer reader silently misreads, because the header claims a newer format than the bytes.
- History. The constant header dates from 2010 and was harmless until LUCENE-9047 (
b84e0c272b6, 9.0.0) made BYTE2 decoding depend on the version; that commit did not touch the save line, and its PR lists "adds backwards compatibility for FST" for reading. #12908 (9.9.1) addedsetVersionfor a test writer of "data in the 9.8 format" without threading it into the header. - Precedent. LUCENE-8844 (#9888, 2019) treated an encoding change without a header bump, which made older readers "exhibit strange behavior", as a bug. When a break was intended, the project said so (LUCENE-3681, 2012: users "will have to rebuild"); no CHANGES, MIGRATE or javadoc entry says that re-saving a loaded older FST is unsupported. Lucene 8.11.4 itself re-saves the file with header 7 and 9 of 9 keys.
The devil's-advocate check245 words
The strongest case (a cross-model review rated it "moderate"): FST is @lucene.experimental, readMetadata disclaims a back-compat promise, setVersion is "Expert", and LUCENE-3681 once told users to rebuild old FSTs. LUCENE-9011 (#10054, 2019) closed a report of a standalone FST file that no longer loaded (IndexFormatTooOldException): jpountz answered that it "has been created by Lucene 6 or earlier". rmuir wrote on java-user@ in 2013 that "mainly FST (unfortunately) has backwards compatibility because parts of the official index format use it", that is, old formats stay readable for index files, which read their FSTs in place. So old standalone FST files are best effort, and an application should rebuild an old FST rather than copy it. Apart from the wrong reported version, the VERSION_90 header only causes a failure on readers capped at 8, which Lucene does not support.
Why it fails for the main symptom: Lucene offers this compatibility deliberately, with a read branch for exactly these files, and reads them correctly; save then turns a correctly loaded FST into a file that answers wrongly (all 9 keys of the repro "not found"), by writing a header that misdescribes bytes it copies unchanged. Best effort can justify a loud IndexFormatTooOldException, which is what LUCENE-9011 got, not silent wrong answers from a public call with no documented precondition. The index-format reason explains why old formats are read at all, not why save should mislabel them. For the second symptom the case is fair.
Recommended Write the FST's own version in the header (recommended)
Write the FST's own version: in FSTMetadata.save, CodecUtil.writeHeader(metaOut, FILE_FORMAT_NAME, version) instead of VERSION_CURRENT. Default-built FSTs are unchanged, and a loaded old FST is re-saved byte-identical. Validated with gradle: the repro, the util.fst tests (0 failures, 6 skipped), the whole backward-codecs module (2,339 tests, 0 failures) and :lucene:core:check -x test.
public void save(DataOutput metaOut) throws IOException {
CodecUtil.writeHeader(metaOut, FILE_FORMAT_NAME, version); // was VERSION_CURRENT
...All options and trade-offs4 options · 541 words
One option was validated with gradle in a clean checkout of ea57360912ed; the others are listed with their trade-offs and were not tested.
One line in FSTMetadata.save (fix-save-own-version.diff; with the test: fix-save-own-version-with-test.diff). For an on-heap FST FST#save copies the bytes unchanged, so the header must keep the version they are in. FSTs built with default settings have version = VERSION_CURRENT (FSTCompiler.java:250), so their files do not change; a loaded old FST is re-saved byte-identical; a setVersion(VERSION_90) FST gets header 8, as 9.0-9.8 wrote.
public void save(DataOutput metaOut) throws IOException {
CodecUtil.writeHeader(metaOut, FILE_FORMAT_NAME, version); // was VERSION_CURRENT
...Trade-offs
- A re-saved old FST stays in its old format; it is not upgraded. If a later release raises
VERSION_START, such files fail loudly withIndexFormatTooOldException. FSTMetadatahas a public constructor that takes anyint version, which now goes into the header as is; an out-of-range value gives a file thatreadMetadatarejects. A range check in the constructor or insavewould catch it early (not tested).- The backward-codecs test data in the 9.0-9.8 format now carries FST header 8 instead of 9; its tests pass either way.
What the checks validated
- Gradle, clean checkout of
ea57360912edwith the patch, seed DEADBEEF, JDK 25: the repro 3 of 3 with-Ptests.asserts=trueand=false(2 of 3 fail without the patch). :lucene:core:test --tests 'org.apache.lucene.util.fst.*'(TestFSTs, TestFSTDirectAddressing, TestUtil, TestFSTSuffixNodeCache, TestBitTableUtil, TestGrowableByteArrayDataOutput, the repro; Test2BFST and Test2BFSTOffHeap are@Ignored) pluscodecs.lucene103.blocktree.*: 44 tests, 0 failures, 6 skipped (3@Nightly, 3@Ignore);util.fst.*with assertions off: 40 tests, 0 failures, 6 skipped (one more needs assertions).- The whole
:lucene:backward-codecs:test, which holds the only in-treesetVersion(VERSION_90)caller and every backward index test: 57 classes, 2,339 tests, 0 failures, 184 skipped. :lucene:core:check -x testpasses with the patch and the test (google-java-format, ecj lint, forbidden APIs).- The byte probe: the re-saved copy is byte-identical to the original and finds 9 of 9 keys; a cross-model review's own tests pass with the same patch.
When version < VERSION_LITTLE_ENDIAN and the input type is BYTE2, write a current-format FST instead of copying, for example by rebuilding it from its own enumeration (IntsRefFSTEnum into a new FSTCompiler). Not tested.
Trade-offs
save stops being a byte copy and costs time and memory proportional to the FST. Swapping the labels in place is not practical, because labels sit at variable offsets inside node encodings. Only worth it if save should migrate old FSTs.
Throw from save when version < VERSION_LITTLE_ENDIAN and the input type is BYTE2, asking the caller to rebuild the FST. Not tested.
Trade-offs
Loud instead of silent, but it breaks copies that work with the recommended fix and leaves the second symptom. A broader "only VERSION_CURRENT" rule would break the backward-codecs writer that saves VERSION_90 FSTs.
Say on FST#save that it always writes the current version and that FSTs loaded from older formats must be rebuilt, not re-saved.
Trade-offs
Leaves the silent failure in place; listed for completeness.
Code reading: an AI tool writing an Alloy model of the FST writer noticed that save ignores setVersion; the model itself did not find it. An independent AI verifier then found the lost keys with a real 8.11.4 file. A cross-model review, a release sweep and a prior-report search followed.
The method in full168 words
Method: code reading (the study's non-formal tier). In the 2026-10-22 study, run 3 of the FST experiment, an AI tool was writing Alloy facts about the FST writer's versions and, reading the code for them, noticed that FSTMetadata.save ignores FSTCompiler.Builder#setVersion (the second symptom). The Alloy model did not produce a counterexample for it.
- A separate AI verifier (FST verifier) wrote its own tests, reproduced the mislabel on main
7cecf79523, judged it low impact, and found the main symptom, which the finder had not reported: it built a BYTE2 FST with the real 8.11.4 jar, loaded it on main (9 of 9 keys) and found 0 of 9 after a re-save. It also checked BYTE1 and BYTE4 (10 of 10) and the 9.8.0 reader. - The study's public-list rerun reproduced it on main
b83101cdd2. In this hunt it was re-verified onea57360912edand release jars, swept over 133 releases with a commit-level A/B, searched for prior reports, and reviewed by an independent model from another family (OpenAI GPT-5.6).
No prior report found (2026-10-26): 246 queries over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, four mailing lists, the JIRA archive and the web; 30 related threads read; the files of all 413 open PRs checked. No open PR touches the line.
Searches4 searches
Verdict new. The closest items are the commits that introduced the two symptoms and the design discussions of the FST version; none reports a re-save problem.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene); downstream repositories | 2026-10-26 | a scripted prior-report search (GitHub 22, commits 3, downstream 2 queries) and 105 manual GitHub searches (apache/lucene including migrated JIRA issues, Elasticsearch, OpenSearch, Solr, lucene-solr, Lucene.NET, Pinot), for example FSTMetadata save, FST header version, BYTE2 endian, re-save FST, setVersion VERSION_90; the 59 items labelled module:core/FSTs; the commit history of FST.java; 9 read-only GitHub API and local git checks (80 + 105 + 42 + 8 + 2 + 9 = 246 queries in all) | no report of either symptom |
| Mailing lists (dev@, java-user@, issues@, commits@), JIRA archive, web | 2026-10-26 | the tool's 36 list, 9 JIRA and 8 web queries; 42 manual list queries, 8 JQL queries, 2 web phrasings | no report |
| Related threads (read) | 2026-10-26 | 30 items, read in full or searched for version, header and save | different things: #12908 (added setVersion), #10089 / PR #107 (LUCENE-9047, the swap), #13549 (moved the header line), #12748 (why the version is bumped), #9888 (LUCENE-8844, precedent), #12911 / #12924 / #12933 (bundled FSTs regenerated), #12697 / #12698 (a different save bug, fixed), LUCENE-3681 (2012 format change), #10054 / LUCENE-9011 (an old standalone FST too old to load, closed), the 2013 java-user@ thread on suggester binary compatibility (why FSTs keep backward compatibility) |
| Open PRs | 2026-10-26 | the changed files of all 413 open apache/lucene PRs | only #16272 changes FST.java (adds FST.loadDirect); the open sandbox PR #12688 copies the same constant-header pattern |
Introduced in 9.0.0 by LUCENE-9047 (b84e0c272b6, 2021-05-03), which made BYTE2 decoding depend on the header version while save kept writing the constant; pinned by a commit-level A/B. All 33 releases from 9.0.0 to 10.5.2 and main are affected, 8.x is not. Not fixed upstream.
Versions tested6 rows
Introduced by LUCENE-9047 "Directory API is now little endian" (b84e0c272b6, PR #107, by @iverase, merged 2021-05-03, first released in 9.0.0). It added VERSION_LITTLE_ENDIAN = 8, kept the header's version in the FST, and added the BYTE2 byte swap in readLabel; it did not touch the save line, which has written the constant VERSION_CURRENT since the first FST (994aaec2ef5, LUCENE-2792, 2010) and was only moved later (87a3bef50f8, LUCENE-9353; #13549). The second symptom came with #12908 (c0fd4404ea3, @jpountz, 2023-12-11), first released in 9.9.1.
An earlier episode with the same cause: in 4.2.0 to 7.7.3, LUCENE-4677 read format-3 arc targets by version while save wrote the current version, so re-saved format-3 files lost all 45 probe keys; 8.0.0 dropped format 3 (ca22f17662c). Those lines are long out of support.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | 0 of 9 keys after re-save, assertions on and off (gradle and harness); the fix passes |
| 9.9.0 to 10.5.2 (22 releases) | affected | re-saved header 9; format-6 and format-7 BYTE2 files: 0 of 45 keys; second symptom from 9.9.1 (the in-memory version 8 is visible from 9.10.0, where FSTMetadata#getVersion first appears) |
| 9.0.0 to 9.8.0 (11 releases) | affected | re-saved header 8; format-6 and format-7 BYTE2 files: 0 of 45 keys |
| 8.0.0 to 8.11.4 (27 releases) | not affected | 8.11.4 re-saves the 8.11.4 file with header 7 and 9 of 9 keys; 8.x readers keep no version |
b84e0c272b6 (LUCENE-9047) vs its parent 8eb4eb26119 | bisect | the commit writes header 8 and finds 0 of 45 keys; its parent writes 7 and finds 45 of 45 |
| 4.2.0 to 7.7.3 (68 releases) | earlier episode | format-3 files lose all 45 probe keys on re-save (LUCENE-4677); end of life |
Independent checks, all "real": the study's verifier (which found the lost-keys symptom), a rerun of the study's bug list, the hunt's re-verification on main and releases, and a cross-model refute review with its own fixtures and a test that looks keys up by arc traversal (high confidence). The fix was validated separately with gradle.
Checks and verdicts235 words
Each check reproduced the bug with its own runs; none relied on the others' numbers.
| Check | Verdict | What it found |
|---|---|---|
| Study verifier (FST) | new bug | Own tests on main 7cecf79523: the mislabel (3 of 6 tests fail), and the re-save of a real 8.11.4 BYTE2 FST (0 of 9 keys); BYTE1 and BYTE4 keep 10 of 10; a real 9.8.0 reader rejects the VERSION_90 file and reads it once the header is patched to 8. |
| Public-list rerun | reproduces | The study's tests on main b83101cdd2 (2026-10-23): 9 run, 5 failed. |
| Hunt re-verification | real | Minimal JUnit on main ea57360912ed with -ea and -da, on 10.5.2 and 9.12.3; cross-version main on 8.11.4 (correct) and 9.0.0 to main (0 of 9); the fixture re-derived with the 8.11.4 jar. |
| Cross-model refute review | real, high | OpenAI GPT-5.6 generated its own 8.11.4 fixtures and wrote a verifier that does its lookups through the public arc-traversal API (getFirstArc, findTargetArc, Arc.isFinal) instead of Util.get: BYTE2 5 of 5 → 0 of 5, BYTE1 control 5 of 5 → 5 of 5, also with assertions off and other seeds; agreed on the root cause; the fix passes its tests, TestFSTs and TestLucene90PostingsFormat. It rated the works-as-designed case "moderate". |
| History and prior-report follow-ups | introduced in 9.0.0; new | Probe on 133 releases and a commit-level A/B; 246 prior-report queries. |
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 549 KB
Finding record, Lucene and upstream sources, repro files and diagram notes565 words
- Registry title
- FSTMetadata.save always writes the current version, so re-saving a pre-9.0 BYTE2 FST breaks its lookups
- Area
- util / FST
- Priority
- Low, rank 916: Kept in Low but raised: re-saving a pre-9.0 BYTE2 FST writes the current version and deterministically breaks its lookups, a legitimate if old migration step rather than a contrived input, but it needs an app-persisted 8.x char-label FST and shows up immediately.
- Impact class
- silent wrong results
- Impact note
- Applications that load a persisted pre-9.0 BYTE2 FST (e.g. a char-keyed dictionary) and save it again on 9.0+, or that use the expert setVersion(VERSION_90); released since 9.0.
- Summary
- You load an FST that was written before 9.0 with BYTE2 labels and save it again with Lucene 9.0 or later. You expect the re-saved FST to work like the original. Instead save() writes the header version VERSION_CURRENT, so readers decode the labels with the wrong byte order and the lookups fail (0 of 9 keys found in the repro); FSTs built with setVersion(VERSION_90) are also labelled 9 and rejected by 9.8.
- Introduced
- LUCENE-9047 (b84e0c272b6, PR #107) [exec: commit vs parent] (2021-05-03)
- First affected version
- 9.0.0 [exec]
- Affected versions
- 9.0.0 to 10.5.2 (33 releases) and main [exec]; 8.x is not affected
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (study searches 2026-10-22; public-list check 2026-10-20..23)
- Lucene commit tested
- main
ea57360912ed(2026-10-26) with gradle in a clean checkout and with the repro harness, assertions on and off; the Maven Central jars 9.0.0, 9.8.0, 9.12.3, 10.0.0 and 10.5.2 (the repro; 8.11.4 as the correct control); a history probe on all 133 lucene-core releases from 3.6.0 to 10.5.2 with a commit-level A/B of the introducing commit; the embedded fixture re-derived with the real 8.11.4 jar; the recommended fix with gradle in a clean checkout ofea57360912ed(2026-10-26).
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/fst/FST.java:113-128, :455-493, :419-420, :524-527, :538-543, :546-557, :593-596, :1191-1196, :1213-1214Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/fst/OnHeapFSTStore.java:84-90; OffHeapFSTStore.java:64-66; Util.java:47-65Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:250, :335-348, :543, :585-587Lucene main ea57360912ed: lucene/MIGRATE.md:1179-1183 (LUCENE-9047 little-endian DataOutput)
- Who is affected
Lucene main ea57360912ed: lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:502, :606; suggest/fst/FSTCompletionBuilder.java:206; suggest/fst/FSTCompletionLookup.java:298; suggest/fst/WFSTCompletionLookup.java:114, :139; suggest/analyzing/FreeTextSuggester.java:297, :356; suggest/document/NRTSuggesterBuilder.java:62, :109 (BYTE1 FSTs, save calls)Lucene main ea57360912ed: lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/TokenInfoDictionaryBuilder.java:101 and TokenInfoDictionaryWriter.java:56; analysis/nori (same files, :94 and :56); analysis/common NormalizeCharMap.java:108, hunspell/ConvTable.java:44; kuromoji UserDictionary.java:91, nori UserDictionary.java:80 (BYTE2 FSTs built fresh)Lucene main ea57360912ed: lucene/backward-codecs/src/test/org/apache/lucene/backward_codecs/lucene90/blocktree/Lucene90BlockTreeTermsWriter.java:535-547 (only setVersion caller)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/fst/FST.java:62; Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:68 (@lucene.experimental)
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/fst/FST.java:113-114, :457-459, :518-529, :553, :1191-1195Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:61-62, :335Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/util/fst/TestFSTs.java:1204-1243Lucene main ea57360912ed: lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/dict/TestTokenInfoDictionary.java:188; analysis/nori/src/test/org/apache/lucene/analysis/ko/dict/TestTokenInfoDictionary.java:195 (testBinaryFSTIsLatestFormat)
- Suggested fix
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:250 (default version)
- TestResavePre90Byte2Fst.java (JUnit test):
repro/LFV-052/src/org/apache/lucene/util/fst/TestResavePre90Byte2Fst.java(46 lines)
It was made separately from this text, from the probe output at ea57360912ed; the alt text describes the scenario it shows.
assets/LFV-052/bug.png(549 KB)
