All findings · Lucene finding · LFV-052 · fstmetadata-save-version-current

FSTMetadata.save always writes the current version, so re-saving a pre-9.0 BYTE2 FST breaks its lookups

Saving an FST always stamps it with the current format version, even when it was loaded from an older file. For a char-keyed (BYTE2) FST from Lucene 8.x, every label then reads back byte-swapped: the re-saved repro copy finds 0 of 9 keys, with no error, since 9.0.0.

Summary

Priority
Low, rank 916Kept 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
silent wrong results
Since
9.0.0 (LUCENE-9047, 2021)introduced 2021-05-03 · about 5 years
Affected
9.0.0 to 10.5.2 (33 releases) and main
Status
documented
Found by
code reading while writing an Alloy model of the FST writer

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

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.

Diagram of the repro: a BYTE2 FST written by Lucene 8.11.4 (header version 7, 2-byte labels in the old byte order) loads with 9 of 9 keys; FST.save copies the same FST bytes but writes header version 9, so the reader stops swapping the label bytes, the label a (0x0061) is read as 0x6100, and the copy finds 0 of 9 keys with no error.
The repro in one picture: the same FST bytes under header 7 and under header 9, and what the reader makes of the label a. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Take a char-keyed (INPUT_TYPE.BYTE2) FST that Lucene 8.11.4 saved: 9 keys such as ab → 1, cat → 5 and 日本 → 8 (FST format 7).
  2. Load it on main (or any release since 9.0.0): getVersion() is 7 and all 9 keys are found. Save it with fst.save(...) and load the copy. You expect the same 9 keys.
  3. You get 0 of 9 keys and no exception. The copy is the original byte for byte except one: the header version, 7 → 9.
  4. 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. save copies the bytes but always writes the current version (9; 8 in 9.0-9.8), so a (0x0061) is read as 0x6100 愀: get("ab") is null, get("愀戀") is 1.
  5. 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).

Who is affected

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.

Terms

  • 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#save and read back with FST.readMetadata plus the FST constructor, or FST.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 (CodecUtil header, 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.

The bug (FST.java at ea57360912ed)

  1. Lucene 8.x wrote BYTE2 labels with the big-endian writeShort. LUCENE-9047 made DataOutput little-endian in 9.0 (MIGRATE.md:1179-1183), and FSTCompiler.writeLabel writes BYTE2 labels with out.writeShort since (FSTCompiler.java:585-587).
  2. readMetadata accepts formats 6 to 9 (:457-459) and keeps the file's version (:493). readLabel swaps 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.
  3. FST#save writes the metadata, then the FST bytes (:524-527): for an on-heap FST, which FST.read and the FST(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#save writes CodecUtil.writeHeader(metaOut, FILE_FORMAT_NAME, VERSION_CURRENT) (:1214), ignoring its own version field.
  4. Reloading the copy, the header says 9, readLabel no longer swaps, and every BYTE2 label is read with its bytes reversed. Util.get returns null for all 9 keys of the repro; nothing throws.

The numbers

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.

The second symptom

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), or save(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, Hunspell ConvTable) 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 writer Lucene90BlockTreeTermsWriter. Apart from the wrong reported version (getVersion() = 9 after readMetadata, 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.

Minimal repro

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.

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

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

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

Why it looks unintended

  • 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"; save copies 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); readMetadata accepts 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), and readLabel has 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 + readMetadata turns 7 into 9 (and 8 into 9) while the bytes stay as they were. Kuromoji's and Nori's TestTokenInfoDictionary.testBinaryFSTIsLatestFormat (#12911) read the bundled FST's header with FST.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.
  • save has no precondition. "Save the FST to DataOutput" (:518-523) and "Writes an automaton to a file" (:529); the FSTCompiler javadoc 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 that save writes 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) added setVersion for 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.

Suggested fix

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.

Write the FST's own version in the header (recommended) (validated by the checks)

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 with IndexFormatTooOldException.
  • FSTMetadata has a public constructor that takes any int version, which now goes into the header as is; an out-of-range value gives a file that readMetadata rejects. A range check in the constructor or in save would 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 ea57360912ed with the patch, seed DEADBEEF, JDK 25: the repro 3 of 3 with -Ptests.asserts=true and =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) plus codecs.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-tree setVersion(VERSION_90) caller and every backward index test: 57 classes, 2,339 tests, 0 failures, 184 skipped.
  • :lucene:core:check -x test passes 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.

Upgrade an old BYTE2 FST to the current format when saving

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.

Refuse to save an old BYTE2 FST

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.

Document it (not a fix)

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.

How it was found

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 on ea57360912ed and 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).

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene); downstream repositories2026-10-26a 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, web2026-10-26the tool's 36 list, 9 JIRA and 8 web queries; 42 manual list queries, 8 JQL queries, 2 web phrasingsno report
Related threads (read)2026-10-2630 items, read in full or searched for version, header and savedifferent 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 PRs2026-10-26the changed files of all 413 open apache/lucene PRsonly #16272 changes FST.java (adds FST.loadDirect); the open sandbox PR #12688 copies the same constant-header pattern

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affected0 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) affectedre-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) affectedre-saved header 8; format-6 and format-7 BYTE2 files: 0 of 45 keys
8.0.0 to 8.11.4 (27 releases) not affected8.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 bisectthe 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 episodeformat-3 files lose all 45 probe keys on re-save (LUCENE-4677); end of life

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (FST) new bugOwn 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 reproducesThe study's tests on main b83101cdd2 (2026-10-23): 9 run, 5 failed.
Hunt re-verification realMinimal 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, highOpenAI 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; newProbe on 133 releases and a commit-level A/B; 246 prior-report queries.

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 notes565 words

Finding record

LFV-052, snapshot of 2026-11-06

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 of ea57360912ed (2026-10-26).

Lucene and upstream sources

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-1214
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/fst/OnHeapFSTStore.java:84-90; OffHeapFSTStore.java:64-66; Util.java:47-65
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:250, :335-348, :543, :585-587
  • Lucene 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-1195
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:61-62, :335
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/util/fst/TestFSTs.java:1204-1243
  • Lucene 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)

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

Repro files

About the diagrams

It was made separately from this text, from the probe output at ea57360912ed; the alt text describes the scenario it shows.

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.