- An FST node has one outgoing arc per label. A node with many arcs near the root (at least 5 at depth 3 or less, or at least 10 anywhere) is written with fixed-length arcs, so a lookup can jump to an arc instead of scanning.
- Such a node is encoded either for binary search over its sorted labels, or for direct addressing: a table with one presence bit for every label from the node's first label to its last, so a lookup indexes straight into it.
- The compiler picks direct addressing when that encoding is not larger than binary search (default factor 1), or only somewhat larger while a running credit from earlier nodes allows it. A BYTE4 FST's writer accepts any non-negative int as a label.
- Build a BYTE4 FST with the default
FSTCompiler.Builderand add five one-label keys: 0, 1, 2, 3 and 2,147,483,646 (Integer.MAX_VALUE - 1), each with output 1. The root node has 5 arcs, enough for fixed-length arcs. - The compiler now picks binary search (35 bytes here) or direct addressing, which needs one presence bit for every label from 0 to 2,147,483,646: 268,435,456 bytes. You expect binary search and an FST that maps every key to 1.
- Instead the size is computed in
int: 2,147,483,647 + 7 wraps around, and>> 3gives -268,435,456 bytes. A negative size looks cheaper than 35 bytes, so direct addressing is chosen. - The writer's copy offset becomes -268,435,436 and
compile()throwsArrayIndexOutOfBoundsException, with assertions on and off. The setting documented to disable direct addressing,directAddressingMaxOversizingFactor(-1), fails the same way. - Any node with fixed-length arcs whose labels span 2^31 - 8 or more hits it, on every release from 8.4.0 to 10.6.0 and on main. No corrupt FST is ever returned.
Only applications that build a BYTE4 FST themselves with arbitrary int labels, when one node with 5 or more arcs (10 deeper down) holds a label from 0 to 7 and one within about 8 of Integer.MAX_VALUE. No in-tree user can: synonyms, stemmer overrides and hunspell use code points. The build fails loudly; no corrupt FST. Rare.
Mechanism, terms and numbers673 words
FSTCompiler writes a node with at least 5 arcs at depth 3 or less, or at least 10 arcs anywhere, with fixed-length arcs, and then chooses binary search or direct addressing. Direct addressing stores one presence bit per label between the node's first and last label, and the compiler sizes that table as getNumPresenceBytes(labelRange) = (labelRange + 7) >> 3 with labelRange = last - first + 1, both in 32-bit int (FSTCompiler.java:540, FST.java:575). No narrower BYTE4 label range is documented, and the writer accepts any non-negative int. When last - first >= 2^31 - 8 the addition overflows: for the root labels {0, 1, 2, 3, 2147483646} the table needs 268,435,456 bytes, and the computation gives -268,435,456. That makes direct addressing look cheaper than binary search's 35 bytes, so it is chosen (FSTCompiler.java:642-647), the writer's buffer offset goes negative (:742) and the first copy throws ArrayIndexOutOfBoundsException: arraycopy: destination index -268435436 out of bounds for byte[11] (:751), with assertions on and off. directAddressingMaxOversizingFactor(-1), documented to disable direct addressing (:321-322), fails the same way. Introduced in 8.4.0 by LUCENE-8920 (068b6babac3); every release from 8.4.0 to 10.6.0 fails, and main is unchanged. Computing the range in long and using binary search when it does not fit fixes it (validated); allowFixedLengthArcs(false) is a workaround. Low impact: no in-tree producer can reach it, and a corrupt FST is never returned.
FSTCompiler(org.apache.lucene.util.fst,@lucene.experimental) builds an FST from sortedIntsRefkeys.FST.INPUT_TYPE"Specifies allowed range of each int input label" (FST.java:68): BYTE1, BYTE2 or BYTE4. BYTE4 labels are written as a vInt; the writer only assertsv >= 0(FSTCompiler.java:580-591), and no narrower range is documented.- Fixed-length arcs: chosen when
allowFixedLengthArcsis true (the default) and the node has at least 5 arcs at depth <= 3 or at least 10 arcs (FSTCompiler.java:601-606). - Direct addressing: one presence bit per label in
[first, last], so its table has(labelRange + 7) >> 3bytes withlabelRange = last - first + 1(FST.java:573-576).shouldExpandNodeWithDirectAddressingcompares its size with binary search's (FSTCompiler.java:617-650).
int labelRange = last - first + 1(FSTCompiler.java:540), guarded only byassert labelRange > 0(:541).getNumPresenceBytescomputes(labelRange + 7) >> 3inint(FST.java:575). ForlabelRange >= 2^31 - 7, that islast - first >= 2^31 - 8, the addition overflows and the result is about -2^28.sizeForDirectAddressingis then negative (:624-627),expansionCost <= 0(:632) and direct addressing is chosen (:642-647) for the default factor and for -1.writeNodeForDirectAddressingOrContinuouscomputes a negativebufferOffset(:742) for the repro's node;ensureCapacitydoes not grow the buffer for a negative size (:743,:1176-1182), and the firstscratchBytes.writeTo(:751) throws fromSystem.arraycopy.
Root labels {0, 1, 2, 3, 2147483646}, output 1 each. Each arc is 1 flag byte, a vInt label (1 byte, or 5 for 2147483646) and a 1-byte output, so maxBytesPerArc = 7 and maxBytesPerArcWithoutLabel = 2. labelRange = 2,147,483,647 (still positive). Presence bytes: 2,147,483,647 + 7 wraps to -2,147,483,642, and >> 3 gives -268,435,456 instead of 268,435,456. Direct addressing size -268,435,456 + 1 + 2 x 5 = -268,435,445; binary search 7 x 5 = 35; expansion cost -268,435,480 (default) or -268,435,410 (factor -1), both <= 0. Writer: bufferOffset = 11 - 268,435,456 + 11 = -268,435,434, minus 2 for the last arc = -268,435,436, the index in the exception.
The exact boundary (a Z3 proof and an executed sweep): with last = Integer.MAX_VALUE, first = 0..7 fails and first = 8 or 9 builds. With first = 0 and last = Integer.MAX_VALUE, labelRange itself wraps to Integer.MIN_VALUE, so -ea stops at the assert (:541) and -da gives the same exception; the repro uses Integer.MAX_VALUE - 1 so both modes match.
Exposure and likelihood268 words
- Who is hit. Code that builds an FST with
FST.INPUT_TYPE.BYTE4and uses labels as arbitrary non-negative ints (ids, hashes, ordinals,Integer.MAX_VALUEorMAX_VALUE - 1as a sentinel), when one node with fixed-length arcs holds a label in [0, 7] and a label at least 2^31 - 8 higher. - Who is not hit. Every in-tree BYTE4 FST uses Unicode code points (<= 0x10FFFF):
SynonymMap,StemmerOverrideFilterand hunspellDictionary. Postings and the terms index use BYTE1, and BYTE1/BYTE2 labels (at most 65,535) cannot span 2^31 - 8. - What happens.
compile()throws an uncheckedArrayIndexOutOfBoundsException(the repro), oradd()does when a later key freezes such a node; the FST cannot be built. - What does not happen. No corrupt FST is returned: for every wrapping input the presence-byte count is exactly -268,435,456, so the copy offsets go below zero before the node is written and a copy always throws. For the repro, and any such node whose arcs total less than about 256 MiB, it is the first copy, with no buffer growth; a node with larger arcs first grows the buffer (to less than its own arc bytes) and fails at a later copy. No hang.
- Default vs opt-in, scale. The builder's defaults trigger it; the opt-in part is using the FST API directly with BYTE4 and huge labels. Five keys suffice. Deterministic, assertions on and off.
- Workarounds.
allowFixedLengthArcs(false)builds and finds every key (a smaller but slower FST, per its javadoc).directAddressingMaxOversizingFactor(-1)does not help. Keeping labels below 2^31 - 8 also avoids it. - How likely. Rare: no report in the almost seven years since 8.4.0.
One JUnit test on the public API: add five one-label keys {0, 1, 2, 3, Integer.MAX_VALUE - 1} to a default BYTE4 FSTCompiler and call compile(). It fails only when the ArrayIndexOutOfBoundsException escapes. Deterministic, single thread, about 2 s; the same 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.IntsRef;
public class TestFSTByte4WideLabelSpan extends LuceneTestCase {
public void testNodeLabelsSpanningAlmostAllInts() throws Exception {
FSTCompiler<Long> compiler =
new FSTCompiler.Builder<>(FST.INPUT_TYPE.BYTE4, PositiveIntOutputs.getSingleton()).build();
for (int label : new int[] {0, 1, 2, 3, Integer.MAX_VALUE - 1}) {
compiler.add(new IntsRef(new int[] {label}, 0, 1), 1L);
}
try {
compiler.compile();
} catch (ArrayIndexOutOfBoundsException e) {
// Expected: compile() builds the FST; actual: AIOOBE (presence-byte count wraps negative).
throw new AssertionError("expected compile() to build the FST, but it threw " + e, e);
}
}
}
cp TestFSTByte4WideLabelSpan.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestFSTByte4WideLabelSpan -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
- Expected
compile()builds the FST; a node too wide for direct addressing is encoded for binary search.- Observed
ArrayIndexOutOfBoundsException: arraycopy: destination index -268435436 out of bounds for byte[11]atFSTCompiler.java:751, with assertions on and off.
More files, environment, full output294 words
TestFSTByte4WideLabelSpan builds a BYTE4 FST with PositiveIntOutputs and the default builder, adds the five single-label keys 0, 1, 2, 3 and Integer.MAX_VALUE - 1 in order (output 1 each) and calls compile(). Five arcs is the smallest root that gets fixed-length arcs. Integer.MAX_VALUE - 1 rather than MAX_VALUE keeps labelRange itself from wrapping, so assertions on and off give the same exception. The try/catch turns the exception into an AssertionError that states expected and actual, with the original as its cause. Public API only (the test sits in org.apache.lucene.util.fst, like Lucene's own FST tests); no randomness of its own (seed DEADBEEF with assertions on and off, and 5EED1234 with assertions off, gave the same failure). It compiles on 9.1 and later.
As the regression test: this same test, for example as a new method in TestFSTDirectAddressing. With the fix it passes, and ./gradlew :lucene:core:check -x test passes with it as a new test file.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 10.6.0, 10.5.2 and 9.12.3; every release from 8.4.0 to 10.6.0 is affected |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp TestFSTByte4WideLabelSpan.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestFSTByte4WideLabelSpan -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
TestFSTByte4WideLabelSpan > testNodeLabelsSpanningAlmostAllInts FAILED
java.lang.AssertionError: expected compile() to build the FST, but it threw java.lang.ArrayIndexOutOfBoundsException: arraycopy: destination index -268435436 out of bounds for byte[11]
at __randomizedtesting.SeedInfo.seed([DEADBEEF:DD7E200A57D2D931]:0)
at org.apache.lucene.util.fst.TestFSTByte4WideLabelSpan.testNodeLabelsSpanningAlmostAllInts(TestFSTByte4WideLabelSpan.java:34)
Caused by:
java.lang.ArrayIndexOutOfBoundsException: arraycopy: destination index -268435436 out of bounds for byte[11]
at java.base/java.lang.System.arraycopy(Native Method)
at org.apache.lucene.util.fst.GrowableByteArrayDataOutput.writeTo(GrowableByteArrayDataOutput.java:92)
at org.apache.lucene.util.fst.FSTCompiler.writeNodeForDirectAddressingOrContinuous(FSTCompiler.java:751)
at org.apache.lucene.util.fst.FSTCompiler.addNode(FSTCompiler.java:549)
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.TestFSTByte4WideLabelSpan.testNodeLabelsSpanningAlmostAllInts(TestFSTByte4WideLabelSpan.java:31)
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on; the same with assertions off.
# 10.6.0, 10.5.2 and 9.12.3: the same exception and index (10.5.2: FSTCompiler.java:750 <- addNode:548).FST.INPUT_TYPE"Specifies allowed range of each int input label"; BYTE4 sets no narrower limit.- The javadoc says factor -1 "effectively disables direct addressing", yet the node still gets it and the build throws.
- A span one label shorter (2^31 - 9) builds and finds every key; the code only asserts that the range is positive.
The full argument276 words
- The input is legal.
FST.INPUT_TYPE: "Specifies allowed range of each int input label for this FST." (FST.java:68); BYTE4 has no narrower documented range.add()documents only the sort order (FSTCompiler.java:840-847).writeLabelwrites BYTE4 labels as a vInt with onlyassert v >= 0(:580-591), and the only reserved label isEND_LABEL = -1(FST.java:139). On #4769 (LUCENE-3695) a maintainer wrote "The INPUT_TYPE really describes the allowed range of the input int labels". - The documented escape hatch fails.
Builder.directAddressingMaxOversizingFactor: "Setting this factor to a negative value (e.g. -1) effectively disables direct addressing, only binary search nodes will be created." (FSTCompiler.java:321-322). The same javadoc scopes the factor to nodes already chosen for fixed-length arcs (:324-326), which this node is (it is not continuous). With -1 the node is still written with direct addressing (addNode:549) and the build throws (executed). - The code assumes the range never wraps.
assert labelRange > 0(FSTCompiler.java:541) andassert labelRange >= 0(FST.java:574) are internal invariants, not input limits. The first direct-array code (LUCENE-8781,5c9e7d53511, 2019-05) checkedlabelRange > 0explicitly; apache/lucene-solr#950 removed that encoding, and LUCENE-8920 brought direct addressing back with only the assert. - Nearby inputs work. The span 2^31 - 9 builds and every lookup is right; the same five keys build with
allowFixedLengthArcs(false)(executed). The failure is internal arithmetic, not a limit of the format. - No sign of intent. The LUCENE-8920 thread (#9963) and the review of apache/lucene-solr#980 discuss the oversizing heuristic and the bit tables, never an int wrap; no upstream FST test uses BYTE4 labels near
Integer.MAX_VALUE.
The devil's-advocate check158 words
The strongest case for "not a bug" (the cross-model reviewer's, which it rated weak): the FST package is @lucene.experimental (FST.java:62, FSTCompiler.java:68), IntsRef is @lucene.internal (IntsRef.java:25), and the Builder javadoc describes BYTE4 as "full unicode codepoints" (FSTCompiler.java:253-255), so labels near Integer.MAX_VALUE could be called outside the intended use.
Why it fails: "experimental" marks the API as unstable, not this input as unsupported; IntsRef is the type add() itself takes; the code-point sentence says how strings are represented and states no upper bound, while INPUT_TYPE is documented as the allowed label range; and even if maintainers consider such spans unsupported, an explicit validation error (for example an IllegalArgumentException) would be clearer than an index error from internal arithmetic. Residual: a maintainer might prefer to document and reject such spans instead of falling back to binary search; that is a choice of fix (option D), and the repro would then use expectThrows.
Recommended A. 64-bit label range; binary search when the presence-byte count would not fit
Compute the label range as a long and allow direct addressing only when it is at most Integer.MAX_VALUE - 7, so the presence-byte count cannot overflow; wider nodes use binary search. A 10-line change in FSTCompiler.addNode, no format change. With it the repro passes (assertions on and off), and so do the FST tests and core's static checks.
All options and trade-offs4 options ยท 693 words
Fix A was validated through gradle in a scratch worktree of 645d4fe14a20. It is the study verifier's patch with its comment reworded. B is an alternative shape, C a workaround for users today, D the option that narrows the contract.
A. 64-bit label range; binary search when the presence-byte count would not fit (validated by the checks) (recommended)
In addNode, compute last - first + 1 as a long and only call shouldExpandNodeWithDirectAddressing when it is at most Integer.MAX_VALUE - 7; otherwise the node falls through to binary search.
--- a/lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java
+++ b/lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java
@@ -537,15 +537,20 @@
assert maxBytesPerArc > 0;
// 2nd pass just "expands" all arcs to take up a fixed byte size
- int labelRange = nodeIn.arcs[nodeIn.numArcs - 1].label - nodeIn.arcs[0].label + 1;
- assert labelRange > 0;
- boolean continuousLabel = labelRange == nodeIn.numArcs;
+ // BYTE4 labels can span more than an int: compute the range in 64 bits, and consider direct
+ // addressing only when its presence-byte count (labelRange + 7) >> 3 cannot overflow.
+ long labelRangeL = (long) nodeIn.arcs[nodeIn.numArcs - 1].label - nodeIn.arcs[0].label + 1;
+ assert labelRangeL > 0;
+ boolean labelRangeFits = labelRangeL <= Integer.MAX_VALUE - 7;
+ int labelRange = (int) labelRangeL; // only used when labelRangeFits or the node is continuous
+ boolean continuousLabel = labelRangeL == nodeIn.numArcs;
if (continuousLabel && version >= FST.VERSION_CONTINUOUS_ARCS) {
writeNodeForDirectAddressingOrContinuous(
nodeIn, maxBytesPerArcWithoutLabel, labelRange, true);
continuousNodeCount++;
- } else if (shouldExpandNodeWithDirectAddressing(
- nodeIn, maxBytesPerArc, maxBytesPerArcWithoutLabel, labelRange)) {
+ } else if (labelRangeFits
+ && shouldExpandNodeWithDirectAddressing(
+ nodeIn, maxBytesPerArc, maxBytesPerArcWithoutLabel, labelRange)) {
writeNodeForDirectAddressingOrContinuous(
nodeIn, maxBytesPerArcWithoutLabel, labelRange, false);
directAddressingNodeCount++;Trade-offs
- No format or reader change: such a node becomes a binary-search node. With the true size (268,435,456 bytes for the repro's node, against 35 bytes for binary search) the heuristic would reject direct addressing anyway.
- Nothing changes for nodes whose span is below 2^31 - 8, so BYTE1, BYTE2 and code-point FSTs are untouched. Factor -1 then behaves as documented for these nodes.
- A continuous node's span equals its arc count, so the
intcast is safe there. Onelongsubtraction per fixed-length node at build time; no read-path cost.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seedDEADBEEF, 2026-11-01: without the fix the repro fails with assertions on and off; with it, it passes with assertions on and off. - Every core test class in
org.apache.lucene.util.fstwith the fix: 9 classes, 38 tests, 0 failures, 5 skipped (includingTestFSTs22,TestFSTDirectAddressing3 and the repro). - analysis-common
TestSynonymGraphFilter(56) andTestStemmerOverrideFilter(5), two in-tree BYTE4 builders: 61 tests, 0 failures. ./gradlew :lucene:core:check -x testwith the fix and the repro as a new test file: passes (google-java-format, ecj lint of main and test code, forbidden APIs; error-prone skipped). The full Lucene test suite was not run.- Earlier, with the same logic shadowed into the stock jars: 4 supporting checks pass with assertions on and off (factor -1 and the default builder build and every key is found; two controls), and the cross-model reviewer's own tests,
TestFSTDirectAddressingandTestFSTspass.
Compute the label range and the direct-addressing size as long in shouldExpandNodeWithDirectAddressing and getNumPresenceBytes, so the heuristic sees the true 256 MiB and rejects the node. Not validated.
Trade-offs
- A single size check instead of a special case, but it changes two signatures and still needs a guard for the full span (first 0, last
Integer.MAX_VALUE), whose range 2^31 cannot be written as the node header's int (FSTCompiler.java:771). - Option A gives the same encoding for every node with less code.
Not a fix. The five keys build and every key is found (executed); the FST is smaller but slower to traverse, per the javadoc. directAddressingMaxOversizingFactor(-1) is not a workaround (executed).
Trade-offs
Slower lookups for every node of the FST, not only the wide one.
Throw an IllegalArgumentException for a node whose labels span 2^31 - 8 or more.
Trade-offs
Narrows the BYTE4 label range that INPUT_TYPE documents, although the encoder already has a valid fallback (binary search).
An application that builds BYTE4 FSTs from arbitrary ints can avoid the crash today with allowFixedLengthArcs(false) or by keeping labels below 2^31 - 8; directAddressingMaxOversizingFactor(-1) does not help.
Formal. An AI tool wrote a Z3 bit-vector model of how FSTCompiler chooses the encoding of a fixed-length-arc node. Its first query, solved in 0.6 s, returned a 5-arc node with labels 6 and Integer.MAX_VALUE whose presence-byte count wraps; a second query proved the exact boundary. A JUnit test on the stock jar then showed the crash.
The method in full196 words
- Method: SMT (Z3) over bit-vector and float arithmetic, the study's formal tier (2026-10-22, main
7cecf79523). The model transcribes the fixed-length-arc encoding decision: the label range, the presence-byte count, the binary-search and direct-addressing sizes, the oversizing factor and the credit rule. - Q1 asked whether direct addressing can be chosen with a size that does not match the real table: SAT in 0.63 s, the first query run, with first label 6, last label
Integer.MAX_VALUEand 5 arcs. Q2 proved the trigger is exactlylast - first >= 2^31 - 8. Q3/Q4 proved the direct-addressing size arithmetic exact, whenever direct addressing is chosen, for BYTE1, BYTE2 and smaller BYTE4 spans, within the model's bounds (up to 65,536 arcs, 4,096 bytes per arc, any factor). - Witness: the counterexample became a JUnit on the stock jar; an independent verifier wrote its own 8-test JUnit (5 fail, 3 controls pass) and a boundary sweep that matched Q2 exactly.
- Later: reruns on two later mains (
b83101cdd2,a9779ef3c193), a one-test minimal repro (2026-10-29), a candidate re-verification and a cross-model review (2026-11-01), an executed history sweep over every release from 7.7.3 to 10.6.0, and this doc's gradle validation.
No prior report found in GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream trackers or the web (116 queries and all 409 open PRs by file, 2026-11-01). The introducing change's threads never mention the wrap. Not fixed upstream as of 2026-11-01.
Searches5 searches
Closest items, none a report of this bug: #9963 and apache/lucene-solr#980 (LUCENE-8920, the introducing change; no mention of an int wrap), #9825 (LUCENE-8781; its exception was a read-side readCeilArc bug), #10326 (LUCENE-9286; a maintainer says factor -1 "should disable the direct addressing"), #12697/#12698 (the same exception type from a wrong meta DataOutput), #12738, #12748, #12646, #4769, #9132, #6094, Elasticsearch #74118. Gaps: 23 of the 26 GitHub queries reused results fetched earlier (19 the same day, the four in:title listings on 2026-10-26); private channels were not searched. Related finding on this site: LFV-007 is a different overflow in the same encoder (the expanded node size reaching 2^31).
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; downstream trackers | 2026-11-01 | scripted prior-report searches: 26 issue/PR queries, 3 commit queries, 2 downstream queries (Elasticsearch, OpenSearch, Solr), comments of the top 30 hits read; 55 GraphQL phrasings, including the method names across all of GitHub | 107 distinct hits, none this bug; 0 hits for each method name |
| Mailing lists, JIRA archive, web | 2026-11-01 | 20 list queries (dev@, java-user@, issues@, commits@), 4 JIRA-archive queries, 6 web queries | 271 distinct hits, none this bug |
| Open PRs | 2026-11-01 | all 409 open apache/lucene PRs by touched file | 4 touch util/fst; none touches FSTCompiler.java; #16272 edits FST.java but not presence bytes or the label range |
| Earlier searches | 2026-10-22 | the study's 26 phrase and AND-of-words searches; #9963 and #9825 read in full | no report |
| Upstream main and branches | 2026-11-01 | the two sites on main f815e679bf6c and branch_10_6; git log -S labelRange, -S getNumPresenceBytes | not fixed; the int arithmetic is still there |
Introduced in 8.4.0 by LUCENE-8920 (068b6babac3, 2019-11-13), which brought direct addressing back with a presence-bit table sized in int. All 55 releases from 8.4.0 to 10.6.0 fail and the seven earlier releases run (7.7.3, 8.0.0-8.3.1) build; both introducing commits fail and their parents pass. Never fixed.
Versions tested6 rows
Introduced by LUCENE-8920, "Reduce the memory used by direct addressing of arcs" (issue #9963, PR apache/lucene-solr#980 by GitHub bruno-roustant, merged 2019-11-13): master commit 068b6babac3, shipped in 8.4.0 through the branch_8x backport 5dd9c4c04bc. It added the presence-bit encoding with a 32-bit labelRange guarded only by assert labelRange > 0 and getNumPresenceBytes = (labelRange + 7) / Byte.SIZE. The earlier direct-array encoding (LUCENE-8781) had checked labelRange > 0 and was removed by apache/lucene-solr#950 before 8.3.0. Later changes moved the code without fixing it: LUCENE-9286 (8.6.0, / Byte.SIZE to >> 3, which changed the index from -268435435 to -268435436), the Builder-to-FSTCompiler rename (LUCENE-9089, 9.0.0), continuous-label nodes (#12748, 9.9.0) and addNode moving into FSTCompiler (#12646).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (standalone and gradle); passes with fix A |
| 10.6.0, 10.5.2, 9.12.3 | affected | minimal repro fails with the same exception and index -268435436 |
| 8.4.0 to 10.6.0 (55 releases) | first affected 8.4.0 | the probe fails on every one with assertions on and off, also with factor -1; the span 2^31-9 control builds |
| 7.7.3, 8.0.0 to 8.3.1 | not affected | builds; all 5 lookups return the right output |
068b6babac3 (master), 5dd9c4c04bc (branch_8x) | introducing commits | fail; their parents c1ac1464540 and 010fb0b9942 pass (each commit's core sources compiled ahead of the 8.4.0 jar, not a full build) |
upstream main f815e679bf6c, branch_10_6 | read | the same int arithmetic (source read, not executed) |
Confirmed by the study's independent verifier (8 tests), a reviewer from another model family (OpenAI GPT-5.6: real, high confidence), reruns on two later mains, a one-test minimal repro and an executed history sweep. This doc's gradle run validated the fix.
Checks and verdicts328 words
Nine checks; none disputed the root cause.
| Check | Verdict | What it found |
|---|---|---|
| finder: Z3 model | SAT; exact boundary proved | Q1 SAT in 0.63 s (first label 6, last Integer.MAX_VALUE, 5 arcs); Q2 proves last - first >= 2^31 - 8; Q3/Q4 prove the direct-addressing size exact, whenever it is chosen, for the other cases within bounds. |
| independent verifier (study) | reproduced | Its own 8-test JUnit on main 7cecf79523: 5 fail with assertions on and off and 3 seeds (default, factor -1, full int range, a 10-arc depth-4 node, the boundary sweep); 3 controls pass; the long-range patch passes all 8 plus TestFSTDirectAddressing and TestFSTs. |
| reruns on later mains | still reproduces | The verifier's tests on b83101cdd2 (2026-10-23) and a9779ef3c193 (2026-10-26): the same 5 failures. |
| minimal repro | verified | Reduced to one test; fails on main 645d4fe14a20 with assertions on and off (seed DEADBEEF; seed 5EED1234 with assertions off); passes with the patch shadowed. |
| candidate re-verification | real | The minimal test fails on main, 10.6.0, 10.5.2 and 9.12.3; supporting checks: factor -1 and the default builder fail, both controls pass; the patch passes everything. |
| cross-model review, refute lens (OpenAI GPT-5.6) | real, high confidence | Its own 4-test JUnit fails 2 of 4 on main ea57360912ed (default and factor -1) with assertions on and off, and on 3 more seeds with assertions off; the patch passes its tests, TestFSTDirectAddressing and TestFSTs; intended-behaviour argument weak. |
| history sweep | introduced by 068b6babac3 | 139 executed runs: all 55 releases 8.4.0-10.6.0 fail, the seven earlier releases run (7.7.3, 8.0.0-8.3.1) pass; both introducing commits fail, their parents pass. |
| prior-report search | no report found | 116 queries, 409 open PRs by file and 21 threads read in full; no report. |
| doc validation (gradle) | fix A validated | Repro fails on stock main with assertions on and off and passes with fix A; the util.fst tests, two BYTE4 analysis tests and core's static checks pass. |
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 406 KB
Finding record, Lucene and upstream sources, repro files and diagram notes450 words
- Registry title
- FSTCompiler throws ArrayIndexOutOfBoundsException on BYTE4 nodes whose labels span 2^31-8 or more
- Area
- util / FST
- Priority
- Low, rank 1149: FSTCompiler throws ArrayIndexOutOfBoundsException at build time on BYTE4 nodes whose labels span to near Integer.MAX_VALUE, a sentinel-label edge case on a niche API, and nothing bad is written.
- Impact class
- crash
- Impact note
- Only direct BYTE4 FST builders with arbitrary int labels; released since 8.4.0.
- Summary
- You build a BYTE4 FST with arbitrary int labels so that one node's labels span 2^31-8 or more. You expect the FST to build, and direct addressing to be off with a factor of -1. Instead the presence-byte count wraps and the build throws ArrayIndexOutOfBoundsException, even with factor -1.
- Introduced
- LUCENE-8920 (068b6babac3, #980) (2019-11-13)
- First affected version
- 8.4.0 [exec]; 7.7.3 and 8.0.0 to 8.3.1 build [exec]
- Affected versions
- 8.4.0 to 10.6.0 (all 55 lucene-core releases) and main 645d4fe14a20 [exec, -ea and -da]; 7.7.3 and 8.0.0 to 8.3.1 not affected [exec]
- 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
- formal: Z3
- Upstream and prior reports
- none found (study searches 2026-10-22; public-list check 2026-10-20..23)
- Lucene commit tested
- main
645d4fe14a20: the minimal repro with assertions on and off, standalone and through gradle; the fix in a scratch worktree with every core test class inorg.apache.lucene.util.fst, analysis-common'sTestSynonymGraphFilterandTestStemmerOverrideFilter, and core's static checks. SHA-1-checked release jars: the minimal repro on 10.6.0, 10.5.2 and 9.12.3; a probe on every release from 7.7.3 to 10.6.0 and on the introducing commits and their parents. The cited lines are unchanged on upstream mainf815e679bf6c(2026-11-01) (read).
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:440-514, :536-556, :580-591, :601-606, :617-650, :728-751, :1168-1182 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/fst/FST.java:68-73, :139, :573-576 (at 645d4fe14a20)
- Who is affected
grep of BYTE4 over lucene/**/src/java at 645d4fe14a20: lucene/analysis/common SynonymMap.java:244, StemmerOverrideFilter.java:214, hunspell Dictionary.java:648lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:289-295 (allowFixedLengthArcs javadoc, at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/fst/FST.java:62, :68-73, :139, :573-576; FSTCompiler.java:68, :253-255, :321-322, :540-541, :580-591, :840-847 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/IntsRef.java:25 (at 645d4fe14a20)
- Suggested fix
lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:289-295, :738-771 (at 645d4fe14a20)
- TestFSTByte4WideLabelSpan.java (JUnit, one test):
repro/LFV-067/src/org/apache/lucene/util/fst/TestFSTByte4WideLabelSpan.java(37 lines)
Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.
assets/LFV-067/bug.png(406 KB)
