All findings · Lucene finding · LFV-1142 · fstcompiler-builder-shares-default-output-across-builds

FSTCompiler.Builder: a second build() shares the first compiler's default output, so the second FST misses its own keys

Calling build() twice on one FSTCompiler.Builder with no dataOutput gives both compilers the same default on-heap buffer. With a -> 1 in the first FST and b -> 2 in the second, the second FST returns null for b and answers a -> 1; 9.9.2 got it right.

Summary

Priority
Low, rank 914With assertions off, building a second FST from the same FSTCompiler.Builder silently gives an FST that misses its own keys, every time, in every release since 9.10.0. It needs the expert, experimental FST API used in a way no Lucene caller uses, so it is rare.
Impact
silent wrong results
Since
9.10.0 (#12624, 2023)introduced 2023-12-07 · about 2 years
Affected
9.10.0 to 10.6.0 and main (5 releases run, all fail; 9.9.2 clean); not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of FSTCompiler.Builder's contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

FSTCompiler.Builder.build() keeps the default output it creates in the Builder, so every later build() hands out the same, already used buffer.

Background: building an FST

  1. An FST (finite state transducer) maps byte strings to outputs, for example a -> 1. Lucene uses FSTs for synonym maps, suggesters and dictionaries. You build one with an FSTCompiler: add the keys in sorted order, then call compile().
  2. FSTCompiler.Builder holds the settings (label type, outputs, RAM limit), and build() returns a compiler. If you set no dataOutput, the compiler writes the FST's bytes into an on-heap buffer, and FST.fromFSTReader(compile(), getFSTReader()) turns that buffer into a readable FST.
  3. Inside the buffer a node is found by its byte address, and address 0 is reserved, so each compiler writes one padding byte first. compile() records the root node's address and the byte count, then freezes the buffer.
Diagram of the bug: one FSTCompiler.Builder with no dataOutput is built twice. The first build stores its default on-heap buffer in the Builder, and the second build hands the same buffer to compiler 2. FST 1 occupies bytes 0 to 3 of the buffer, with its root node at address 3. Compiler 2 writes its own bytes at positions 4 to 7 but numbers them as if the buffer were empty, so it records root address 3. Reading from address 3, FST 2 finds FST 1's node: it answers a -> 1 and returns null for its own key b.
Both compilers write into one buffer. FST 1 fills bytes 0-3 with its root at address 3. Compiler 2 appends its bytes at 4-7 but counts from 0, so FST 2's root address is also 3: it reads FST 1's node, answers a -> 1 and misses b. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Create one FSTCompiler.Builder (BYTE1 labels, PositiveIntOutputs, no dataOutput). Call build(), add a -> 1, and make FST 1 with FST.fromFSTReader(compile(), getFSTReader()), the recipe in compile()'s javadoc.
  2. Call build() again on the same Builder, add b -> 2, and make FST 2 the same way. You expect FST 2 to map b to 2, as it does with two Builders and as the same calls did on 9.9.2.
  3. Instead, with assertions off, FST 2 returns null for b and returns 1 for a, a key it never got. With assertions on, the second compile() throws an AssertionError in ReadWriteDataOutput.writeByte.
  4. Why: the first build() stored its default buffer in the Builder, so compiler 2 writes into the same buffer. FST 1 fills bytes 0-3 and its root is at address 3. Compiler 2 appends its bytes at 4-7 but counts addresses from 0, so FST 2's root address is also 3: FST 1's root.

Who is affected

Code that keeps one FSTCompiler.Builder, sets no dataOutput, and calls build() more than once, for example one FST per field or batch. It is deterministic: the second FST is wrong whenever the first is non-empty and the second holds other non-empty keys, silently with assertions off. No Lucene module does this, and FSTCompiler is @lucene.experimental. Releases 9.10.0 to 10.6.0.

Mechanism, terms and numbers499 words

FSTCompiler.Builder.build() creates the default on-heap output when the caller set no dataOutput, but it stores it in the Builder's own field (FSTCompiler.java:352-355 at 5adff78bb168). A second build() on the same Builder therefore gives the new compiler the same buffer, which the first compile() already froze. The new compiler appends its bytes after the first FST's bytes but counts its node addresses from the start, so the second FST's root address points into the first FST. With a -> 1 in the first FST and b -> 2 in the second, the second FST returns null for b and 1 for a with assertions off; with assertions on, the second compile() fails on assert frozen == false in ReadWriteDataOutput.writeByte. Up to 9.9.2 each compiler made its own byte store, so the same calls gave two correct FSTs; #12624 (first released in 9.10.0) introduced the stored default. It is latent in Lucene itself: no Lucene module builds twice from one Builder.

The bug, step by step (main 5adff78bb168)

  1. First build(). dataOutput is null, so build() creates a ReadWriteDataOutput and assigns it to the Builder's own field: if (dataOutput == null) { dataOutput = getOnHeapReaderWriter(15); } (FSTCompiler.java:352-355). Compiler 1 writes its padding byte and its root node; the buffer holds 4 bytes, 00 01 61 1f. compile() records root address 3 and 4 bytes, and freezes the buffer (finish(), :960-965).
  2. Second build(). The field is no longer null, so compiler 2 gets the same buffer object (a probe prints same output object: true).
  3. Compiler 2 counts from 0. Every compiler starts its byte counter at 1, reserving address 0 for its padding byte (:172-175), whatever the buffer already holds. Its padding byte and root node really land at bytes 4-7: 00 01 61 1f 00 02 62 1f. Writes after freeze() are guarded only by assert frozen == false (ReadWriteDataOutput.java:52, :58): with assertions off they go through; with assertions on, the first one, the padding byte (writePaddingByte, FSTCompiler.java:576), throws inside the second compile().
  4. FST 2 reads FST 1. FST 2's metadata says root address 3 and 4 bytes, the same values as FST 1's. getFSTReader() returns the whole shared buffer (:228-234), so FST 2's root is FST 1's root node: get(b) is null and get(a) is 1.
  5. FST 1's save() grows. FST.save copies the whole buffer (FST.java:524-527, ReadWriteDataOutput.writeTo, :128-130): after the second build it writes 8 data bytes while its metadata still says 4. An independent probe with cat -> 5 and dog -> 7 measured 16 bytes against 8. Anything a caller writes after it in the same stream would be read at the wrong offset on reload (by reading; reload not executed).

Not claimed: a caller-supplied dataOutput shared across builds. That is the caller's choice; only the default, which the caller never chooses or supplies, is shared without their asking.

Exposure and likelihood198 words
  • Who is hit. Code that keeps one configured Builder without a dataOutput and builds several FSTs from it. Default settings, one key per FST and no scale are enough. The second FST is wrong whenever the first FST is non-empty and the second holds other non-empty keys, and a third build() is wrong the same way. Two cases happen to look right with assertions off: building the same keys twice, and an empty first FST (its compile() returns null before writing anything).
  • Silent without assertions. With assertions disabled (the JVM default) there is no error: FST 2 misses its own keys and can answer FST 1's (get(a) is 1 in the probe). Only with assertions on does it fail, on an internal assert.
  • Narrow reach. FSTCompiler is marked @lucene.experimental (FSTCompiler.java:68). All 53 new FSTCompiler.Builder sites in the tree build once; the three that keep a Builder variable (TestUtil, TestFSTs, FSTTester) also call build() once. Lucene's own analyzers, codecs and suggesters are not affected, and the recorded searches found no report since 9.10.0 (2024).
  • Side effect. FST 1's save() afterwards also writes FST 2's bytes, after metadata that counts only its own.

Minimal repro

One JUnit test method using public methods only: one Builder, two build() calls, one key each. Deterministic and single-threaded (the test method itself takes about 0.04 s); it fails with assertions on and off.

TestFSTCompilerBuilderReuse.java (lucene/core, package org.apache.lucene.util.fst, 1 test)41 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 org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.IntsRefBuilder;

public class TestFSTCompilerBuilderReuse extends LuceneTestCase {

  public void testSecondBuildFromSameBuilder() throws Exception {
    FSTCompiler.Builder<Long> builder =
        new FSTCompiler.Builder<>(FST.INPUT_TYPE.BYTE1, PositiveIntOutputs.getSingleton());
    IntsRefBuilder scratch = new IntsRefBuilder();

    FSTCompiler<Long> c1 = builder.build();
    c1.add(Util.toIntsRef(new BytesRef("a"), scratch), 1L);
    FST.fromFSTReader(c1.compile(), c1.getFSTReader());

    FSTCompiler<Long> c2 = builder.build();
    c2.add(Util.toIntsRef(new BytesRef("b"), scratch), 2L);
    FST<Long> fst2 = FST.fromFSTReader(c2.compile(), c2.getFSTReader());

    // expected: the second FST maps b -> 2; actual: null (-da), AssertionError in compile() (-ea)
    assertEquals(Long.valueOf(2), Util.get(fst2, new BytesRef("b")));
  }
}
Command
cp $REPRO/TestFSTCompilerBuilderReuse.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestFSTCompilerBuilderReuse -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false (assertions off): expected:<2> but was:<null>
Expected
FST 2 maps b to 2: Util.get(fst2, "b") returns 2.
Observed
Assertions off: expected:<2> but was:<null>; assertions on: AssertionError at ReadWriteDataOutput.writeByte:52 inside the second compile().

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

More files, environment, full output203 words

TestFSTCompilerBuilderReuse (one method, extends LuceneTestCase, package org.apache.lucene.util.fst of the lucene/core tests). It builds FST 1 with a -> 1 and FST 2 with b -> 2 from the same Builder, using the FST.fromFSTReader(compile(), getFSTReader()) recipe from compile()'s javadoc, and asserts that FST 2 maps b to 2. The keys go in through IntsRefBuilder/IntsRef, which are marked @lucene.internal, exactly as in the FST package example (util/fst/package-info.java:47-50).

The test uses no randomness, so the seed does not matter (the hunt's earlier, longer version of the same scenario failed the same way with seeds 5EED0901, 5EED0902 and 7A3D9E11). As the regression test, the same method fits into TestFSTs as is.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.10.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0: fail; 9.9.2: clean (the same scenario on the 9.9 API)
jdkOpenJDK 25.0.3
osLinux aarch64 (nothing platform-specific)
Command (environment)
cp $REPRO/TestFSTCompilerBuilderReuse.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestFSTCompilerBuilderReuse -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false (assertions off)

Observed output

# -Ptests.asserts=false
TestFSTCompilerBuilderReuse > testSecondBuildFromSameBuilder FAILED
    java.lang.AssertionError: expected:<2> but was:<null>
        at org.apache.lucene.util.fst.TestFSTCompilerBuilderReuse.testSecondBuildFromSameBuilder(TestFSTCompilerBuilderReuse.java:39)

# default (assertions on)
TestFSTCompilerBuilderReuse > testSecondBuildFromSameBuilder FAILED
    java.lang.AssertionError
        at org.apache.lucene.util.fst.ReadWriteDataOutput.writeByte(ReadWriteDataOutput.java:52)
        at org.apache.lucene.util.fst.FSTCompiler.writePaddingByte(FSTCompiler.java:576)
        at org.apache.lucene.util.fst.FSTCompiler.addNode(FSTCompiler.java:561)
        at org.apache.lucene.util.fst.FSTSuffixNodeCache.add(FSTSuffixNodeCache.java:154)
        at org.apache.lucene.util.fst.FSTCompiler.compileNode(FSTCompiler.java:388)
        at org.apache.lucene.util.fst.FSTCompiler.compile(FSTCompiler.java:1014)
        at org.apache.lucene.util.fst.TestFSTCompilerBuilderReuse.testSecondBuildFromSameBuilder(TestFSTCompilerBuilderReuse.java:36)

Why it looks unintended

  • build() says "Creates a new FSTCompiler"; nothing says or enforces single use, while other Lucene APIs that mean it say so (SegmentInfo.setCodec, PackedLongValues.Builder).
  • Up to 9.9.2 each compiler made its own buffer, so the same calls gave two correct FSTs; #12624's review discussed the allocation, never reuse.
  • build() itself creates the shared buffer; two Builders work.
The full argument265 words
  • Contract. build(): "Creates a new {@link FSTCompiler}." (FSTCompiler.java:350). The Builder is a "Fluent-style constructor for FST" (:237) whose setters return this. Nothing in the Builder, build() or dataOutput(...) javadoc (:301-315), CHANGES.txt or MIGRATE.md says a Builder is single-use, and nothing enforces it. Other Lucene APIs state single use explicitly, for example SegmentInfo.setCodec ("Can only be called once.", checked only by an assert, index/SegmentInfo.java:174-176) and PackedLongValues.Builder, which throws IllegalStateException("Cannot be reused after build()") on add() after build() (util/packed/PackedLongValues.java:237, :248, :267).
  • History. Before #12624 the Builder's only storage setting was bytesPageBits (a page size, not a store), and each compiler made its own store (bytes = new BytesStore(bytesPageBits), FSTCompiler.java:134 at b6999818474^); 9.9.2 passes the same scenario. In #12624's review, an earlier revision created the default in a field initializer, and mikemccand asked "can we avoid even creating this, unless the caller didn't pass a Builder.dataOutput themselves?" (r1415507219, 2023-12-05). The creation moved into build() but still wrote the field. The thread is about the allocation; nobody discusses building twice. One earlier comment may come up: on a BytesStore revision mikemccand asked "Is caller allowed to freeze() more than once?" (r1391064549, 2023-11-13; no reply in the exported thread). It is about the output's freeze(), not Builder reuse, but with assertions off the second compile() here does call freeze() a second time on the same output.
  • Controls. Two Builders, or one Builder given a fresh explicit output for each build, give two correct FSTs (cross-model review's control runs).
The devil's-advocate check151 words

The argument. Builders are single-use by convention: the compiler itself is single-use (a second compile() on it returns null, because the first one cleared the root node, FSTCompiler.java:402, :1002-1005; and finish() refuses a second call with "already finished", :954-956), ReadWriteDataOutput's frozen assert shows one on-heap buffer is meant for exactly one FST, the API is @lucene.experimental, and no Lucene caller reuses a Builder.

Why it fails. The single-use object is the compiler, not the Builder, which holds only configuration. The frozen assert protects a buffer from a second writer, and it is build() that hands the buffer to one. Other Lucene APIs that mean single use say so; nothing says it here. And the same calls worked through 9.9.2.

What survives. A maintainer may prefer to document "one Builder per FST" (option C) rather than change the code; the code fix costs nothing for single use.

Suggested fix

Recommended A. Create the default in a local variable (recommended)

Create the default output in a local variable in build(), so every compiler gets its own: DataOutput out = dataOutput != null ? dataOutput : getOnHeapReaderWriter(15); and pass out. A caller-set dataOutput is unchanged. The repro passes with assertions on and off, and 274 FST and suggest tests pass.

All options and trade-offs3 options ยท 388 words

Option A restores the pre-9.10 behaviour (one buffer per compiler) and allocates exactly what a single build allocates today. Options B and C are policy choices for maintainers who prefer to declare the Builder single-use.

A. Create the default in a local variable (recommended) (validated by the checks)

build() keeps the caller's dataOutput if one was set and otherwise creates a fresh on-heap output for this compiler only, without writing the Builder field.

     /** Creates a new {@link FSTCompiler}. */
     public FSTCompiler<T> build() {
-      // create a default DataOutput if not specified
-      if (dataOutput == null) {
-        dataOutput = getOnHeapReaderWriter(15);
-      }
+      // create a default DataOutput if not specified, a fresh one for every compiler
+      DataOutput out = dataOutput != null ? dataOutput : getOnHeapReaderWriter(15);
       return new FSTCompiler<>(
           inputType,
           suffixRAMLimitMB,
           outputs,
           allowFixedLengthArcs,
-          dataOutput,
+          out,
           directAddressingMaxOversizingFactor,
           version);
     }

Trade-offs

None for single use: it allocates the same one output per build() as today. A caller-supplied dataOutput is still passed to every compiler, as before (sharing it stays the caller's choice).

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on, and -Ptests.asserts=false) and passes with it (both).
  • With the fix: :lucene:core:test --tests 'org.apache.lucene.util.fst.*' (9 classes, 38 tests, 5 skipped) and :lucene:suggest:test (31 classes, 236 tests, 7 skipped) pass with 0 failures; :lucene:core:checkGoogleJavaFormat passes.
  • Independently: the cross-model reviewer ran TestFSTs with the same change (22 tests found; the 20 enabled ones pass; one skipped and one nightly-only test not run), and 9.10.0 with the same change passes the repro.

B. Make the Builder single-use (not run)

Throw IllegalStateException on a second build(), the way PackedLongValues.Builder rejects add() after build().

Trade-offs

Turns the silent wrong result into a loud error, but breaks code that worked through 9.9.2 and code that reuses a Builder with a fresh explicit output for each build.

What the checks validated

Not run.

C. Document "one Builder per FST" (not run)

State in the build() javadoc that a Builder without an explicit dataOutput must be built only once.

Trade-offs

No code change, but the misuse stays silent with assertions off. Can be added on top of A if maintainers want to state the intended use.

What the checks validated

Not run (documentation only).

Downstream note

Downstream code that reuses one FSTCompiler.Builder for several FSTs would silently get FSTs that miss their own keys and can answer the first FST's entries; no Lucene module does this.

How it was found

A proof-obligation audit of FSTCompiler.Builder's contract: since build() "Creates a new FSTCompiler", every compiler from one Builder must build an FST independent of earlier builds. build() writing its default output into the Builder field breaks that obligation on the second call; a one-method JUnit test using public methods then witnessed the wrong lookup.

The method in full88 words

The audit took the documented contract of FSTCompiler.Builder.build() and checked what state each call leaves behind. The default output is assigned to a Builder field rather than a local variable, so the obligation "a new compiler, independent of earlier ones" fails from the second call on.

An executable one-test repro witnessed it on main with assertions on and off. An independent reproducer, an executed cross-model review (OpenAI GPT-5.6, refute lens), a devil's-advocate verifier and an adjudication then confirmed it, and a version sweep placed its start in 9.10.0.

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits (incl. the JIRA archive), the mailing lists, downstream trackers or the web (searched 2026-11-02; GitHub issues and PRs re-checked 2026-11-03). Nearest: #12624, the PR that introduced it, whose review discussed only the allocation.

Searches3 searches

Hits were history only: #12624 (the introducing PR; review comment r1415507219 about creating the default only when the caller set none), its issue #12543, #12830 (build() throws IOException), #12831 (compile() returns FSTMetadata, the fromFSTReader recipe), #12698 (a different metadata bug) and #12981 (lazy padding byte). Upstream main 1c2072f0284 (2026-11-02) still has the line; no open PR touches FSTCompiler.java or ReadWriteDataOutput.java.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-02; re-checked 2026-11-03four scripted prior-report searches (158 queries: class and method names, the frozen assert, 'build twice', 'same builder', single-use wording) plus a 39-query earlier run and 27 manual searches; the re-check searched 'FSTCompiler.Builder', 'getOnHeapReaderWriter', 'FSTCompiler build twice', 'ReadWriteDataOutput frozen' and everything updated since 2026-11-02no report; #12624 and its review are the nearest
Open PRs2026-11-02the changed files of all 409 open apache/lucene PRs4 touch util/fst (#16272, #14477, #12295, #12688); none touches FSTCompiler.java or ReadWriteDataOutput.java
Mailing lists, downstream trackers, web2026-11-02dev@, java-user@, issues@ and commits@ queries; Elasticsearch, OpenSearch and Solr searches; 4 web searchesnothing about building twice from one Builder

History

Introduced by #12624 (b6999818474, 2023-12-07) and first released in 9.10.0; 9.9.2 is clean. Every release run since then fails, through 10.6.0 and main. Not fixed upstream.

Versions tested5 rows

git log -S 'dataOutput = getOnHeapReaderWriter' on FSTCompiler.java lists only b6999818474 (main) and 193d8f14107 (branch_9x), both #12624 "Allow FST builder to use different writer (#12543)" by Dzung Bui (GitHub dungba88), merged by mikemccand; git blame at 5adff78bb168 still attributes lines 352-355 to b6999818474. Before it, each compiler made its own BytesStore.

9.10.0 with only build() changed to a local variable passes, which pins the cause to these lines. The commit and its parent were not built from source. Every release tag from 9.10.0 to 10.6.0 contains the line; the ones not listed below were checked by reading.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off; passes with fix A
10.6.0 affectedfails with assertions on and off
9.10.0, 9.12.3, 10.0.0, 10.5.2 affectedfail: expected:<2> but was:<null> (or the equivalent in the independent test)
9.10.0 + build() change only line isolationpasses with assertions on and off
9.9.2 9.9 APIthe same scenario passes with assertions on and off

Independent checks

A finder, an independent reproducer, an executed cross-model review (OpenAI GPT-5.6, real, high), a devil's-advocate verifier, an adjudication, an audit and a final repro check reproduced it. This doc's gradle runs reproduced it on main and validated fix A.

Checks and verdicts230 words

The cross-model review ran on main ea57360912ed, where FSTCompiler.java, ReadWriteDataOutput.java and FST.java are byte-identical to 5adff78bb168.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedThe test fails on main (assertions on and off), 10.6.0 and 9.12.3; fix A passes.
independent reproducer realOwn test (cat -> 5, dog -> 7) fails on main (on, off), 10.6.0, 10.5.2, 9.12.3 and 9.10.0; 9.9.2 passes; FST 1's save() writes 16 bytes while numBytes() is 8.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own test fails (assert; null instead of 29) on two seeds; two-Builder and fresh-output controls pass; fix A passes it and the 20 enabled TestFSTs tests (2 of 22 not run).
devil's-advocate verifier real (0.8)Steelmanned single-use builders, the frozen assert, the experimental API and no in-tree caller, and rebutted each; probe prints f1.a=1 f2.b=null f2.a=1 on main, 10.6.0 and 9.10.0.
adjudication, separate re-runs, audit and final repro check confirmed, LowRe-runs fail on main with assertions on and off (seeds 7A3D9E11, 5EED0901, 5EED0902) and pass with fix A; one minimal deterministic test.
this doc reproduced; fix A validatedGradle on a scratch worktree: the repro fails without the fix and passes with it; 274 FST and suggest tests pass; a byte-layout probe shows FST 2's root address equal to FST 1's.

Diagram file

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

Sources and provenance

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

Finding record

LFV-1142, snapshot of 2026-11-06

Registry title
FSTCompiler.Builder.build() stores its lazily created default on-heap DataOutput in the Builder, so a second build() from the same Builder shares the first compiler's output and the second FST silently misses its own keys
Area
util / FST (FSTCompiler.Builder.build: the default on-heap DataOutput)
Priority
Low, rank 914: With assertions off, building a second FST from the same FSTCompiler.Builder silently gives an FST that misses its own keys, every time, in every release since 9.10.0. It needs the expert, experimental FST API used in a way no Lucene caller uses, so it is rare.
Impact class
silent wrong results
Impact note
Only application code that keeps one configured FSTCompiler.Builder (an expert, @lucene.experimental API), sets no dataOutput and calls build() more than once is hit: every FST after the first silently misses its own keys under -da [exec] (and can answer the first FST's keys instead, the intended-behaviour check probe), and the first FST's save() afterwards also writes the later FST's bytes after metadata that counts only its own (the independent reproduction probe: 16 data bytes written, numBytes 8), while under -ea the second compile() fails on an internal assert; default settings, tiny input, no scale needed; no in-tree caller builds twice from one Builder; releases 9.10.0 to 10.6.0 and main.
Summary
You create one FSTCompiler.Builder (BYTE1, PositiveIntOutputs, no dataOutput set), build() a compiler, add a -> 1 and read the FST back with FST.fromFSTReader(compile(), getFSTReader()) as compile()'s javadoc shows, then call build() again on the same Builder for a second FST with b -> 2; you expect two independent FSTs, as two Builders give and as the same code gave up to 9.9.2. Instead the second FST returns null for b with assertions off (the first still answers a -> 1), and with -ea the second compile() fails on an assert in ReadWriteDataOutput.writeByte. build() writes its default output into the Builder's own dataOutput field, so the second compiler appends to the first compiler's already frozen output while numbering its node addresses from 1, and its root points into the first FST's bytes.
Introduced
b6999818474c518953d1290ef304c84dedb04ab6 (#12624 'Allow FST builder to use different writer (#12543)', author dungba88, merged by mikemccand; branch_9x copy 193d8f14107) [read: git log -S 'dataOutput = getOnHeapReaderWriter' on FSTCompiler.java gives only these two commits and git blame on main still points the four lines at b6999818474; exec: 9.9.2 passes the same scenario written against the 9.9 API, 9.10.0 fails, and 9.10.0 with only build() shadowed to use a local variable passes, -ea and -da; the commit and its parent were not built from source] (2023-12-07 (author date of b6999818474 on main; PR merged 2023-12-07T14:26:04Z; branch_9x commit 193d8f14107 committed 2023-12-08))
First affected version
9.10.0 [exec: fails with -ea and -da; 9.9.2 passes the same scenario on the 9.9 API, -ea and -da]
Affected versions
9.10.0 to 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT). Executed, fail [exec]: main -ea and -da (finder, the independent reproduction, the intended-behaviour check, judge and the registration check), 10.6.0 -ea and -da, 10.5.2 -da, 10.0.0 -da, 9.12.3 -da, 9.10.0 -ea and -da. Executed, pass: 9.9.2 (-ea and -da, the scenario on the pre-9.10 API; before #12624 each compiler made its own BytesStore). 9.11.0-9.12.2 and 10.1.0-10.5.1 by reading (the line is in every one of those tags, unchanged per blame). Not new in 10.6 (since 9.10.0). Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) still assigns the field (FSTCompiler.java:354), and the newest commit on the file is 8cd74fed97b (#15133, author date 2026-09-29, ramBytesUsed accounting). All runs on Linux aarch64 with JDK 25.0.3.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-02, on top of the finder's, the independent reproduction's and the intended-behaviour check's searches; rechecked the same day): four scripted prior-report searches (158 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive) plus the finder's 39-query run, 27 manual GitHub searches, 4 web searches, the changed files of all 409 open apache/lucene PRs (4 touch util/fst: #16272, #14477, #12295, #12688; none touches FSTCompiler.java or ReadWriteDataOutput.java), and full reads of #12624 (105 review comments, 61 reviews, 20 issue comments), #12543, #12830, #12831, #12695 and others. Hits are history only: #12624 (the introducing PR; its review comment r1415507219 by mikemccand asked to create the default only when the caller set none, about the allocation, not reuse), #12543 (its issue), #12830, #12831, #12698, #12981. Same-day recheck: GitHub search 'FSTCompiler Builder', 'FSTCompiler reuse', 'getOnHeapReaderWriter' and 'FSTCompiler dataOutput' return only those history items; nothing FST-related was updated since 2026-10-31; the newest PRs (#16954-#16966) are unrelated; upstream main 1c2072f0284 still has the line. Not fixed anywhere.
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT): gradle in a scratch worktree for this doc (the repro fails with assertions on and off and passes with fix A; 274 FST and suggest tests pass with fix A) and standalone JUnit runs; release jars 9.10.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 fail; 9.9.2 passes the same scenario on the 9.9 API; 9.10.0 with only build() changed passes. The cross-model review ran on main ea57360912ed, whose FSTCompiler.java, ReadWriteDataOutput.java and FST.java are byte-identical.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:350-363 (at 5adff78bb168) (build(): 'if (dataOutput == null) { dataOutput = getOnHeapReaderWriter(15); }' assigns the Builder field; javadoc 'Creates a new {@link FSTCompiler}.'), lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:172-175, :228-234, :961-965, :972-985 (compile() javadoc recipe), lucene/core/src/java/org/apache/lucene/util/fst/ReadWriteDataOutput.java:51-60 (writes after freeze() only asserted), :128-130 (writeTo copies every byte)
  • a Lucene main checkout: grep of 'new FSTCompiler.Builder' (53 sites) and of stored Builder variables (TestUtil.java:107, TestFSTs.java:340, FSTTester.java:251, each built once)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:350-363 (at 5adff78bb168) (build(): '/** Creates a new {@link FSTCompiler}. */', 'if (dataOutput == null) { dataOutput = getOnHeapReaderWriter(15); }' at :352-355)
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:172-175 (at 5adff78bb168) (numBytesWritten++ for the padding byte, in every compiler), :228-234 (getFSTReader returns the whole output), :574-578 (writePaddingByte), :960-965 (finish: startNode, numBytes, freeze), :972-985 (compile() javadoc recipe)
  • lucene/core/src/java/org/apache/lucene/util/fst/ReadWriteDataOutput.java:51-60 (at 5adff78bb168) (writes after freeze() only asserted), :128-130 (writeTo copies every byte)
  • lucene/core/src/java/org/apache/lucene/util/fst/FST.java:524-527 (at 5adff78bb168) (save: metadata.save, fstReader.writeTo)
  • lucene suggest, analysis (SynonymMap, hunspell, kuromoji, nori) and codecs sources at 5adff78bb168 (in-tree FSTCompiler users)
Who is affected
  • Lucene main 5adff78bb168: grep of 'new FSTCompiler.Builder' (53 sites); stored Builder variables TestUtil.java:107, TestFSTs.java:340, test-framework FSTTester.java:251, each built once
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:68 (at 5adff78bb168) (@lucene.experimental)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:237, (at 5adff78bb168) :301-315, :350
  • lucene/core/src/java/org/apache/lucene/util/packed/PackedLongValues.java:237, (at 5adff78bb168) :248, :267
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfo.java:174-176 (at 5adff78bb168) ('Can only be called once.', 'assert this.codec == null'); lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:402 (nodeIn.clear() in compileNode), :954-956 (finish: 'already finished'), :1002-1005 (compile() returns null when the root has no arcs)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/packed/PackedLongValues.java:211-218 (at 5adff78bb168) (build(), no reuse check), :237 (add() after build() throws)
Diagrams
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:350-363 (at 5adff78bb168) (build(): '/** Creates a new {@link FSTCompiler}. */', 'if (dataOutput == null) { dataOutput = getOnHeapReaderWriter(15); }' at :352-355)
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:172-175 (at 5adff78bb168) (numBytesWritten++ for the padding byte, in every compiler), :228-234 (getFSTReader returns the whole output), :574-578 (writePaddingByte), :960-965 (finish: startNode, numBytes, freeze), :972-985 (compile() javadoc recipe)

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

Repro files

About the diagrams

The byte values are those a probe on main prints with assertions off (FSTCompiler.java:172-175, :352-355, :960-965).

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.