FSTCompiler.Builder.build() keeps the default output it creates in the Builder, so every later build() hands out the same, already used buffer.
- 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 anFSTCompiler: add the keys in sorted order, then callcompile(). FSTCompiler.Builderholds the settings (label type, outputs, RAM limit), andbuild()returns a compiler. If you set nodataOutput, the compiler writes the FST's bytes into an on-heap buffer, andFST.fromFSTReader(compile(), getFSTReader())turns that buffer into a readable FST.- 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.
a -> 1 and misses b. Select the still to open it at full size. Generated with AI, checked against the code.- Create one
FSTCompiler.Builder(BYTE1 labels,PositiveIntOutputs, nodataOutput). Callbuild(), adda -> 1, and make FST 1 withFST.fromFSTReader(compile(), getFSTReader()), the recipe incompile()'s javadoc. - Call
build()again on the same Builder, addb -> 2, and make FST 2 the same way. You expect FST 2 to mapbto 2, as it does with two Builders and as the same calls did on 9.9.2. - Instead, with assertions off, FST 2 returns null for
band returns 1 fora, a key it never got. With assertions on, the secondcompile()throws anAssertionErrorinReadWriteDataOutput.writeByte. - 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.
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.
- First
build().dataOutputis null, sobuild()creates aReadWriteDataOutputand 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). - Second
build(). The field is no longer null, so compiler 2 gets the same buffer object (a probe printssame output object: true). - 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 afterfreeze()are guarded only byassert 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 secondcompile(). - 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 andget(a)is 1. - FST 1's
save()grows.FST.savecopies 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 withcat -> 5anddog -> 7measured 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
dataOutputand 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 thirdbuild()is wrong the same way. Two cases happen to look right with assertions off: building the same keys twice, and an empty first FST (itscompile()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.
FSTCompileris marked@lucene.experimental(FSTCompiler.java:68). All 53new FSTCompiler.Buildersites in the tree build once; the three that keep a Builder variable (TestUtil,TestFSTs,FSTTester) also callbuild()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.
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.
/*
* 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")));
}
}
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
bto 2:Util.get(fst2, "b")returns 2. - Observed
- Assertions off:
expected:<2> but was:<null>; assertions on:AssertionErroratReadWriteDataOutput.writeByte:52inside the secondcompile().
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.
| lucene | main 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) |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (nothing platform-specific) |
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)
# -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)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 returnthis. Nothing in the Builder,build()ordataOutput(...)javadoc (:301-315),CHANGES.txtorMIGRATE.mdsays a Builder is single-use, and nothing enforces it. Other Lucene APIs state single use explicitly, for exampleSegmentInfo.setCodec("Can only be called once.", checked only by an assert,index/SegmentInfo.java:174-176) andPackedLongValues.Builder, which throwsIllegalStateException("Cannot be reused after build()")onadd()afterbuild()(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:134atb6999818474^); 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 aBuilder.dataOutputthemselves?" (r1415507219, 2023-12-05). The creation moved intobuild()but still wrote the field. The thread is about the allocation; nobody discusses building twice. One earlier comment may come up: on aBytesStorerevision mikemccand asked "Is caller allowed tofreeze()more than once?" (r1391064549, 2023-11-13; no reply in the exported thread). It is about the output'sfreeze(), not Builder reuse, but with assertions off the secondcompile()here does callfreeze()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.
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.
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, seedDEADBEEF: 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:checkGoogleJavaFormatpasses. - Independently: the cross-model reviewer ran
TestFSTswith 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.
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.
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 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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-02; re-checked 2026-11-03 | four 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-02 | no report; #12624 and its review are the nearest |
| Open PRs | 2026-11-02 | 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 |
| Mailing lists, downstream trackers, web | 2026-11-02 | dev@, java-user@, issues@ and commits@ queries; Elasticsearch, OpenSearch and Solr searches; 4 web searches | nothing about building twice from one Builder |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | gradle: fails with assertions on and off; passes with fix A |
| 10.6.0 | affected | fails with assertions on and off |
| 9.10.0, 9.12.3, 10.0.0, 10.5.2 | affected | fail: expected:<2> but was:<null> (or the equivalent in the independent test) |
| 9.10.0 + build() change only | line isolation | passes with assertions on and off |
| 9.9.2 | 9.9 API | the same scenario passes with assertions on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | The test fails on main (assertions on and off), 10.6.0 and 9.12.3; fix A passes. |
| independent reproducer | real | Own 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, Low | Re-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 validated | Gradle 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. |
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 451 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,270 words
- 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 onlybuild()changed passes. The cross-model review ran on mainea57360912ed, whoseFSTCompiler.java,ReadWriteDataOutput.javaandFST.javaare byte-identical.
- 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 oncelucene/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, :350lucene/core/src/java/org/apache/lucene/util/packed/PackedLongValues.java:237, (at 5adff78bb168) :248, :267lucene/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)
- TestFSTCompilerBuilderReuse.java (lucene/core, package org.apache.lucene.util.fst, 1 test):
repro/LFV-1142/TestFSTCompilerBuilderReuse.java(41 lines)
The byte values are those a probe on main prints with assertions off (FSTCompiler.java:172-175, :352-355, :960-965).
assets/LFV-1142/bug.png(451 KB)
