- An FST maps sorted keys to outputs. With input type BYTE4 a key is a sequence of int labels, each any value >= 0. Lucene's own BYTE4 FSTs (synonyms, stemmer overrides, hunspell) store Unicode code points.
IntsRefFSTEnumwalks the keys in order, like aSortedMapiterator.seekCeil(t)moves to the smallest key >= t;seekFloor(t)to the largest key <= t.- The same enum can seek again, forward or backward. To save work it first keeps the labels its current key shares with the new target, then searches only the rest. Where the target has no more labels, the enum uses the marker label -1 (
END_LABEL).
[3, MAX] and is asked for the ceiling of [3]. At the second label it compares MAX with the target's end marker -1 by subtraction: 2147483647 - (-1) wraps to -2147483648, so it keeps the MAX arc and returns [3, MAX]. Integer.compare gives +1, resets to the node's first arc and returns [3]. Select the still to open it at full size. Generated with AI, checked against the code.- Build a BYTE4 FST with two keys:
[3]-> 1 and[3, 2147483647]-> 2.2147483647isInteger.MAX_VALUE, a legal BYTE4 label. - On one
IntsRefFSTEnum, callseekCeil([3, MAX]): it correctly lands on[3, MAX]. Then callseekCeil([3])on the same enum. - You expect
[3]with output 1: it exists, so it is its own ceiling, and a fresh enum returns exactly that. - Instead you get
[3, MAX]with output 2, andseekFloor([3])returnsnull. No error, with assertions on or off. - Why: to reuse its position, the enum compares its current label with the target's label by subtracting them. The target
[3]has run out, which the enum marks as label -1, and2147483647 - (-1)wraps to-2147483648. So the enum thinks the target lies ahead and keeps the MAX arc.
With in-range seek targets, only application code that puts the label Integer.MAX_VALUE into its own BYTE4 FST and reuses one IntsRefFSTEnum to seek back to a prefix of its current key; also a reused enum given a target with a huge negative label. No Lucene component does either. Every release from 3.3 to 10.6.0.
Mechanism, terms and numbers613 words
IntsRefFSTEnum.seekCeil and seekFloor begin with FSTEnum.rewindPrefix(), which keeps the part of the current term that the new target shares and decides, label by label, whether to seek forward or reset to the node's first arc. It decides with final int cmp = getCurrentLabel() - getTargetLabel(); (FSTEnum.java:79 at 5adff78bb168). Once the target is used up, getTargetLabel() returns FST.END_LABEL, -1 (IntsRefFSTEnum.java:91-97). If the current label there is Integer.MAX_VALUE, a legal BYTE4 label, 2147483647 - (-1) wraps to Integer.MIN_VALUE, the code takes the "seek forward" branch and keeps the MAX arc. In the BYTE4 FST {[3] -> 1, [3, MAX] -> 2}, an enum positioned on [3, MAX] answers seekCeil([3]) with [3, MAX] and seekFloor([3]) with null; a fresh enum answers both correctly. The line dates from 2011 (LUCENE-2843) and is in every release from 3.3 to 10.6.0. No Lucene component builds a BYTE4 FST with that label; the fix is one Integer.compare.
IntsRefFSTEnum.seekCeil(IntsRefFSTEnum.java:60-65) stores the target and callsdoSeekCeil, which first callsrewindPrefix()(FSTEnum.java:136) "to match the shared prefix between current term and target term" (:66).rewindPrefix(FSTEnum.java:67-94) walks positionsupto = 1, 2, ...of the current term and at:79(the fix site) computescmp = getCurrentLabel() - getTargetLabel().cmp < 0("seek forward") breaks and keeps the current arc (:80-83);cmp > 0("seek backwards") resets this position to the node's first arc (:84-89).- Current term
[3, MAX], target[3]. Position 1:3 - 3 = 0, go on. Position 2: the target is used up, sogetTargetLabel()returnsFST.END_LABEL= -1 (IntsRefFSTEnum.java:93-94;FST.java:139). The right answer is MAX > -1, reset. But2147483647 - (-1) = 2147483648does not fit in an int and wraps to-2147483648, socmp < 0and the MAX arc is kept. doSeekCeil's scan (FSTEnum.java:139-161) continues from the kept arc with target label -1. On this list-encoded nodedoSeekCeilListsees label MAX > -1 and callspushFirst()(:286-288), which completes[3, MAX]. After a reset, the node's first arc would be the final (END_LABEL) arc, which matches -1 and gives[3].doSeekFloorcalls the samerewindPrefix(:328):seekFloor([3])returnsnull(executed).doSeekExactalso calls it (:653) but then finds the arc withfindTargetArc, soseekExactis correct (executed). Positioning on[3, MAX]with twonext()calls instead ofseekCeiltriggers it too (executed).
The labels an FST stores are >= 0 (asserted in FSTCompiler.java:581 and :1109; Java asserts only, so nothing checks it with assertions off), and a target made of such labels adds only END_LABEL = -1 as a negative value, so the subtraction can overflow only for Integer.MAX_VALUE - (-1). With label 0x7ffffffe the same reused-enum sequence is correct: reusedCeil=[3]/1 reusedFloor=[3]/1; with 0x7fffffff: reusedCeil=[3 7fffffff]/2 reusedFloor=null. BytesRefFSTEnum labels are 0-255 and cannot overflow.
Out-of-range targets. seekCeil/seekFloor do not check the target: getTargetLabel() returns the caller's own ints (IntsRefFSTEnum.java:96), and no javadoc states a label range for seek targets. A target label at least 2^31 below the current label wraps the same line. On the BYTE4 FST {[1] -> 1, [3] -> 2} (no MAX label), an enum first positioned by seekCeil([3]) answers seekCeil([Integer.MIN_VALUE]) and seekCeil([-2147483645]) with [3]/2, while a fresh enum gives [1]/1; -2147483644 (no wrap) is correct; the Integer.compare line fixes both (executed). A side remark, not a second repro.
Exposure and likelihood218 words
- Who. Application code that builds its own BYTE4 FST containing the label
Integer.MAX_VALUEand reuses oneIntsRefFSTEnumtoseekCeilorseekFloorto a proper prefix of its current term whose next label is that MAX label. Two keys suffice; no scale, default settings. Outside the label range, also code that seeks a reusedIntsRefFSTEnumwith a target holding a huge negative label (seekCeil, executed). - Effect. A silent wrong lookup:
seekCeilreturns a term (and its output) that is not the ceiling;seekFloorreturnsnullfor a target that exists. Same with assertions on and off. - Not affected. Lucene's BYTE4 FSTs hold code points <= 0x10FFFF (
SynonymMap.java:244,StemmerOverrideFilter.java:214, hunspellDictionary.java:648inlucene/analysis/common). The only non-testIntsRefFSTEnumuser, hunspellWordFormGenerator.java:68-71, callsnext()only. The codecs' term readers useBytesRefFSTEnum(byte labels). A fresh enum andseekExactare correct. - Opt-in part.
FST,FSTCompilerandIntsRefFSTEnumare@lucene.experimental(FST.java:62,FSTCompiler.java:68,IntsRefFSTEnum.java:26). - Likelihood: rare. No caller found does it and no public report of it was found since 2011. It is deterministic, though: whoever uses MAX as a label, reuses the enum and then seeks to a proper prefix whose next current label is MAX gets the wrong answer every time; other in-range seeks after a MAX term compare without overflow.
One JUnit test method on the public FST classes: a two-key BYTE4 FST, two seekCeil calls on one enum, one assertion. Deterministic, single-threaded, about 1 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.IntsRef;
public class TestFSTEnumSeekAfterMaxLabel extends LuceneTestCase {
public void testSeekCeilToPrefixAfterMaxLabel() throws Exception {
IntsRef shortTerm = new IntsRef(new int[] {3}, 0, 1);
IntsRef longTerm = new IntsRef(new int[] {3, Integer.MAX_VALUE}, 0, 2);
FSTCompiler<Long> compiler =
new FSTCompiler.Builder<>(FST.INPUT_TYPE.BYTE4, PositiveIntOutputs.getSingleton()).build();
compiler.add(shortTerm, 1L);
compiler.add(longTerm, 2L);
FST<Long> fst = FST.fromFSTReader(compiler.compile(), compiler.getFSTReader());
IntsRefFSTEnum<Long> fstEnum = new IntsRefFSTEnum<>(fst);
fstEnum.seekCeil(longTerm);
// expected: [3] (it exists, so it is its own ceiling); actual: [3, 7fffffff]
assertEquals(shortTerm, fstEnum.seekCeil(shortTerm).input);
}
}
cp $REPRO/TestFSTEnumSeekAfterMaxLabel.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestFSTEnumSeekAfterMaxLabel -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure
- Expected
[3], as a fresh enum returns.- Observed
[3, 7fffffff], a key above the ceiling:java.lang.AssertionError: expected:<[3]> but was:<[3 7fffffff]>.
More files, environment, full output197 words
TestFSTEnumSeekAfterMaxLabel (one method, extends LuceneTestCase, package org.apache.lucene.util.fst of the lucene/core tests). It uses the public FSTCompiler, FST and IntsRefFSTEnum and builds its keys as IntsRef, which is @lucene.internal but is the type the enum's seek methods take. It builds {[3] -> 1, [3, Integer.MAX_VALUE] -> 2} with FSTCompiler.Builder(BYTE4, PositiveIntOutputs), calls seekCeil([3, MAX]) and then asserts that seekCeil([3]) on the same enum returns [3].
As the regression test, the same method fits in TestFSTs. seekFloor([3]) on the same enum returns null too; the test asserts only the seekCeil result. IntsRef.toString prints labels in hex, so 7fffffff is Integer.MAX_VALUE.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.12.3, 10.5.2, 10.6.0: the same test fails; 3.3.0 to 8.11.4 (4 releases): an older-API form fails; 3.2.0: no FST package |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (nothing platform-specific) |
cp $REPRO/TestFSTEnumSeekAfterMaxLabel.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestFSTEnumSeekAfterMaxLabel -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure
TestFSTEnumSeekAfterMaxLabel > testSeekCeilToPrefixAfterMaxLabel FAILED
java.lang.AssertionError: expected:<[3]> but was:<[3 7fffffff]>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:40F03DFA408425C1]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:120)
at org.junit.Assert.assertEquals(Assert.java:146)
at org.apache.lucene.util.fst.TestFSTEnumSeekAfterMaxLabel.testSeekCeilToPrefixAfterMaxLabel(TestFSTEnumSeekAfterMaxLabel.java:36)
(gradle on main 5adff78bb168, seed DEADBEEF, assertions on; with -Ptests.asserts=false the same assertion)seekCeilpromises the "smallest term that's >= target" andseekFloorthe "biggest term that's <= target", with no fresh-enum rule; a fresh enum is correct.INPUT_TYPEdocuments the "allowed range of each int input label"; BYTE4 accepts any label >= 0.- Backward seeks are designed:
rewindPrefixhas a seek-backwards branch, and Lucene's FST tests reuse one enum for random-order seeks.
The full argument238 words
- Contract.
IntsRefFSTEnum.java:59: "Seeks to smallest term that's >= target";:67: "Seeks to biggest term that's <= target". The package doc says the enumerators "behave like SortedMap iterators" (package-info.java:30-32) and "Like TermsEnum, this also supports seeking (advance)" (:73). - Legal label.
FST.java:68: INPUT_TYPE "Specifies allowed range of each int input label for this FST".FSTCompiler.writeLabelasserts BYTE1 <= 255 and BYTE2 <= 65535 but for BYTE4 onlyv >= 0, written as a vInt (FSTCompiler.java:580-590; Java asserts, not run-time checks). Input order isIntsRef.compareTo, a signedArrays.compare(IntsRef.java:106-107), so MAX is just the largest label. Michael McCandless described the input type the same way on LUCENE-3695 (#4769): "The "INPUT_TYPE" really describes the allowed range of the input int labels", and "FST is just like a SortedMap". - Backward seeks.
rewindPrefixexists to reuse the current position and has a "seek backwards -- reset this arc to the first arc" branch (FSTEnum.java:84-89).FSTTesterin the test framework reuses oneIntsRefFSTEnumforseekCeil,seekFloorandseekExacton random targets in random order (FSTTester.java:420-520), only with byte and code-point labels, so it never reaches the overflow. - History. Before LUCENE-2843 the forward-only
advance(target)(never released) said "NOTE: target must be >= where we are already positioned"; LUCENE-2843 replaced it withseekCeil/seekFloorwithout such a note. No commit, issue or PR since calls the subtraction deliberate.
The devil's-advocate check143 words
The strongest case for "intended": the Builder javadoc says strings are represented as BYTE4 "full unicode codepoints" (FSTCompiler.java:253-255), every in-tree BYTE4 FST stays <= 0x10FFFF, the API is @lucene.experimental, and IntsRef itself is @lucene.internal (IntsRef.java:25); so labels near 2^31 are off-label.
Why it fails: the same javadoc says the input type "Can be anything from INPUT_TYPE enumeration"; the code-points sentence is an example of use, and no narrower BYTE4 range is documented or enforced. @lucene.experimental allows API changes, not wrong answers; IntsRefFSTEnum's public seek methods take an IntsRef, so every caller of them builds one; and the failure is silent rather than a clear rejection. The cross-model reviewer rated this case moderate and rejected it, as did the independent reproducer, the devil's-advocate verifier and the adjudication. A maintainer who prefers to restrict labels has fix B.
Recommended Compare, do not subtract (fix A)
Compare the labels with Integer.compare(getCurrentLabel(), getTargetLabel()) instead of subtracting them: one line in FSTEnum.rewindPrefix. The repro passes with assertions on and off, and the selected FST and FST-based postings-format suites show 0 failures (188 tests, 43 skipped). Open PR #14477 rewrites the same loop.
- final int cmp = getCurrentLabel() - getTargetLabel();
+ final int cmp = Integer.compare(getCurrentLabel(), getTargetLabel());All options and trade-offs2 options ยท 491 words
Fix A was built and checked through gradle on 5adff78bb168; B was not run. Open PR #14477 ("Use Arrays#mismatch in FSTEnum#rewindPrefix", a performance change) keeps getCurrentLabel() - getTargetLabel() for the first mismatching label, so the same change belongs there if it lands first. Open PR #12688 copies the compare into a new PrimitiveLongFSTEnum that only sees byte labels and cannot overflow, so a change there would only be for consistency. Scope: fix A covers the in-range case (MAX against END_LABEL) and the executed out-of-range negative-target case (a list-encoded root node). On array-encoded nodes the seek code does more label arithmetic on the target (FSTEnum.java:166, :196, :362, :393, :507, :529; FST.java:1047, :1068, :1085; Util.java:917), which was not tested with such targets; whether seek targets must hold labels >= 0 is for the maintainers to state, as nothing documents or checks it today.
In FSTEnum.rewindPrefix (FSTEnum.java:79), replace the subtraction with Integer.compare.
- final int cmp = getCurrentLabel() - getTargetLabel();
+ final int cmp = Integer.compare(getCurrentLabel(), getTargetLabel());Trade-offs
No functional change for any pair of labels that does not overflow: the sign is the same as before. Not benchmarked; the JMH FSTEnumSeekBenchmark of open PR #16095 could measure it.
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, the
org.apache.lucene.util.fsttest classes in core (the repro,TestFSTs22,TestUtil5,TestFSTDirectAddressing3 and 5 more classes) and, in codecs,TestFSTPostingsFormat,TestVarGapDocFreqIntervalPostingsFormat,TestVarGapFixedIntervalPostingsFormatandTestUniformSplitPostingFormat(30 each, 2 skipped each) ran, plusTestSimpleTextPostingsFormat, which is@Nightly, so all 30 of its tests were skipped: 188 tests, 0 failures, 43 skipped (also both 2B tests), so 145 ran and passed.checkGoogleJavaFormaton core passes. These suites are regression evidence only: the codec suites useBytesRefFSTEnum(labels 0-255), which cannot reach the overflow. The full core suite was not run, and only OpenJDK 25.0.3 on Linux aarch64 was used. - Earlier: the hunt's shadow-class runs of the same line pass the repro (assertions on and off); the independent reproducer's and the cross-model reviewer's own tests pass with it; on the 3.3.0 jar the same one-line change turns the failure into a pass.
- The fact check's out-of-range probe (
seekCeil([Integer.MIN_VALUE])on a reused enum over{[1], [3]}) gives[1]/1 with the fix, as a fresh enum does (executed, assertions off).
Document BYTE4 labels as < Integer.MAX_VALUE (or as code points) and reject larger labels in FSTCompiler.
Trade-offs
An API restriction instead of a bug fix: it breaks applications that already use MAX, does nothing for FSTs already written with it, and is more code than A.
What the checks validated
Not run.
Downstream code that uses Integer.MAX_VALUE as a label in its own BYTE4 FST and reuses an IntsRefFSTEnum would silently get wrong ceiling and floor lookups, as would code that passes a seek target with a huge negative label to a reused enum; no Lucene component builds such an FST or seeks an IntsRefFSTEnum.
A proof-obligation audit of FSTEnum's seek contract: for every label >= 0, a seek on a reused enum must match a fresh enum. It fails where an int subtraction stands in for a comparison, at one in-range pair, MAX against the end marker -1; a JUnit test witnessed it. A later fact check: huge negative target labels wrap too.
The method in full161 words
Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).
- The finder checked
rewindPrefixagainst the obligation thatseekCeil/seekFlooron a reused enum equal the fresh-enum answer for every label BYTE4 accepts (anyv >= 0) and found thatcmp = getCurrentLabel() - getTargetLabel()changes sign forInteger.MAX_VALUE - (-1). - The finder then wrote a JUnit test and the one-line
Integer.comparefix that makes it pass; the repro was later cut to a single method with two keys. - An independent reproducer, an executed cross-model review and a devil's-advocate verifier reproduced it with their own tests, and an adjudication re-ran the finder's and the reproducer's tests ("Prior reports, history and checks").
- The audit took target labels to be >= 0 as well. The doc's fact check later ran a reused enum with a huge negative target label (
Integer.MIN_VALUE) and showed that it wraps the same line; the same fix covers that run.
No prior report found in apache/lucene issues, PRs and commits (incl. the JIRA archive), the mailing lists, downstream trackers or the web (last checked 2026-11-03). Nearest: open PR #14477, a performance rewrite of the same loop that keeps the subtraction and never mentions overflow.
Searches3 searches
No matching public report was found in the documented searches. Nearest items, none a report of this bug: #14477 (open, "Use Arrays#mismatch in FSTEnum#rewindPrefix", keeps the subtraction), #16095 (open, its benchmark), #12688 (open, copies the compare for byte labels), #3917 (LUCENE-2843, where the code was written) and #6094 (LUCENE-5030: a 2013 comment proposed Integer.MAX_VALUE as a sentinel label, dropped because those labels must pass through UTF32ToUTF8). Lucene.NET's FSTEnum.cs has the same subtraction; no Lucene.NET issue mentions it. Limits: keyword search; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-02; re-checked the same day and on 2026-11-03 | a scripted prior-report search (183 distinct hits, none high) and about 30 manual searches: rewindPrefix, getTargetLabel, IntsRefFSTEnum, BYTE4, seekCeil/seekFloor with overflow, Integer.MAX_VALUE, Integer.compare | no report; #14477 is the only change to the same loop |
| Open and recent PRs | 2026-11-02 and 2026-11-03 | the changed files of all 409 open apache/lucene PRs, of the 34 PRs updated since 2026-10-31 and of the 11 updated since 2026-11-02 | only #14477 (and the benchmark #16095) touch FSTEnum; none fixes it |
| Mailing lists, downstream, web | 2026-11-02 | dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch, Solr, Lucene.NET and the archived lucene-solr; GitHub code search; web queries | nothing about this bug |
Present since LUCENE-2843 wrote the generic seek (1cab313a2b8, 2011), first released in 3.3 through the branch_3x copy; 3.2.0 has no FST package. Every release run since then, through 10.6.0 and main, fails. Not fixed upstream.
Versions tested4 rows
Introduced by LUCENE-2843 "Add variable-gap terms index impl." (Michael McCandless; now #3917): 1cab313a2b8 on trunk, 2011-01-05, which created FSTEnum with seekCeil, seekFloor and rewindPrefix. The branch_3x copy came with LUCENE-3140 (#4213), 43c373b207e, 2011-05-29; first release 3.3, and 4.0.0-alpha on the trunk line. Four later commits touched rewindPrefix (LUCENE-3225, LUCENE-4117, LUCENE-8920, the LUCENE-9570 reformat); none changed the compare.
Releases before 9.10 ran main() versions of the same scenario with the older FST API. On the 3.3.0 jar, changing only this compare to Integer.compare turns the failure into a pass. By reading, all 130 release tags from 3.3 to 10.6.0 contain the line. Upstream main 1c2072f0284 (2026-11-02) still has it; the newest commit on FSTEnum.java is 570832eb74a (2023-11-09).
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | gradle: fails with assertions on and off; passes with fix A |
| 9.12.3, 10.5.2, 10.6.0 | affected | the JUnit test: [3 7fffffff] instead of [3] |
| 3.3.0, 3.6.2, 4.0.0, 8.11.4 | older-API form | the same wrong ceiling; 3.3.0 with only the compare changed passes |
| 3.2.0 | API absent | compile error: no FST package |
A finder, an independent reproducer, an executed cross-model review (OpenAI GPT-5.6, real, high), a devil's-advocate verifier, an adjudication and a final repro check reproduced it. This doc's gradle runs reproduced it on main and validated fix A.
Checks and verdicts336 words
The cross-model review ran on the older main ea57360912ed, where FSTEnum.java and IntsRefFSTEnum.java are byte-identical to 5adff78bb168; its test was then re-run on 5adff78bb168 (2 of 3 methods fail with assertions off; all 3 pass with the fix). Every other check ran on 5adff78bb168 or release jars.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Two-key BYTE4 FST: seekCeil([3]) on the reused enum gives [3 7fffffff] on main (assertions on and off), 10.6.0 and 9.12.3; the one-line fix passes. |
| independent reproducer | real | Own test fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3; controls pass (label MAX-1, fresh enum, seekExact); seekFloor([3]) gives null; next() positioning also triggers it; its own fix passes. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own 3-key test ([17], [17, MAX-1], [17, MAX]) fails with assertions on and off; fresh-enum and MAX-1 controls pass; seekFloor gives null; the fix passes it and targeted FST tests. |
| devil's-advocate verifier | real | No documented narrower BYTE4 range and no fresh-enum rule; probe: label 0x7ffffffe correct, 0x7fffffff wrong only on the reused enum; fails on the 10.6.0 jars. |
| adjudication, separate re-runs, credibility audit and final repro check | confirmed (high), Low | Re-runs on main fail with assertions on and off and pass with the fix; one minimal deterministic test (two keys, two seeks). |
| this doc | reproduced; fix A validated | Gradle without the fix fails (on, off); with it the selected suites show 0 failures (188 tests, 145 ran, 43 skipped). |
| doc fact checks (independent, and cross-model OpenAI GPT-5.6) | bug, repro and fix confirmed; scope wording corrected | Re-ran the repro (fail on main, assertions on and off; pass with the fix). Found that a reused enum given a target with a huge negative label wraps the same line (executed; the same fix cures it), so the scope sentences now say "with in-range seek targets"; the SimpleText suite was skipped (@Nightly); IntsRef is @lucene.internal. |
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 500 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,137 words
- Registry title
- A reused IntsRefFSTEnum gives wrong seekCeil/seekFloor results after visiting a term with label Integer.MAX_VALUE, because FSTEnum.rewindPrefix compares labels by subtraction and it overflows
- Area
- util / FST (FSTEnum.rewindPrefix label compare; IntsRefFSTEnum seekCeil/seekFloor)
- Priority
- Low, rank 929: A reused IntsRefFSTEnum returns a wrong seekCeil or seekFloor result after a term with the label Integer.MAX_VALUE, in every release since 3.3. It needs that exact label in an application-built BYTE4 FST of the expert FST API, which no Lucene caller uses, so its reach is very narrow.
- Impact class
- minor
- Impact note
- Only application code that uses the expert, @lucene.experimental FST API directly is hit: in a BYTE4 FST that holds the label Integer.MAX_VALUE, an IntsRefFSTEnum reused to seek to a proper prefix of its current term whose next label is that MAX label silently gets a term above the ceiling from seekCeil, or null from seekFloor although the target exists, with -ea and -da; tiny input, no scale needed, default settings otherwise; no in-tree caller can reach it (in-tree BYTE4 FSTs hold code points, BytesRefFSTEnum labels are <= 255, seekExact is not affected); every release since 3.3 and main.
- Summary
- You build a BYTE4 FST with [3] -> 1 and [3, Integer.MAX_VALUE] -> 2, move one IntsRefFSTEnum to [3, MAX] with seekCeil, then call seekCeil([3]) on the same enum; you expect [3], the smallest term >= [3], as a fresh enum returns. Instead it returns [3, MAX] (and seekFloor([3]) returns null), with or without assertions. FSTEnum.rewindPrefix compares labels as getCurrentLabel() - getTargetLabel(); once the target is used up its label is END_LABEL (-1), so MAX - (-1) wraps to Integer.MIN_VALUE and the enum keeps the MAX arc instead of going back to the node's first arc.
- Introduced
- 1cab313a2b88d3e1d1365af4e30f06f7a0d49547 (LUCENE-2843, now #3917, 'Add variable-gap terms index impl.', mikemccand, trunk r1055405; branch_3x copy 43c373b207e with LUCENE-3140, now #4213, r1128870) [read: git log -S 'getCurrentLabel() - getTargetLabel()' and git blame -w -M -C -C on the history clone give only these commits, and no later commit changed the line; exec: 3.2.0 has no FST API, 3.3.0 fails with -ea and -da, and 3.3.0 with only the compare changed to Integer.compare passes; the commits and their parents were not built from source (SVN-era builds)] (2011-01-05 (author date of 1cab313a2b8 on trunk; branch_3x copy 43c373b207e: 2011-05-29))
- First affected version
- 3.3 [exec: 3.3.0 fails with -ea and -da; 3.2.0 has no FST package (compile error)]; on the trunk line 4.0.0-alpha (4.0.0 executed: fail)
- Affected versions
- 3.3 to 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT). All 130 release tags from 3.3 to 10.6.0 in the history clone contain the line [read]; 8.8.2-8.11.3 are untagged there and affected by inference (8.8.1 has the line, 8.11.4 fails). Executed, fail [exec]: main -ea and -da (finder, the independent reproduction, the cross-model review re-check, the intended-behaviour check, judge and the registration check), 10.6.0 -ea and -da, 10.5.2 -ea and -da, 9.12.3 -ea and -da, 8.11.4 -da, 4.0.0 -ea and -da, 3.6.2 -da, 3.3.0 -ea and -da (releases before 9.10 through main() adapters with the same scenario and assertions). Not affected: every release before 3.3 (no FST package; 3.2.0 executed: compile error). Not new in 10.6. Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) still has the subtraction at FSTEnum.java:79, and the newest commit on FSTEnum.java is 570832eb74a (author date 2023-11-09). 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): a scripted prior-report search over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive (183 distinct hits, none high), about 30 manual GitHub searches, 6 web searches, GitHub code search, the changed files of all 409 open apache/lucene PRs and of the 34 PRs updated since 2026-10-31 (none of the recent ones touches util/fst), and full reads of #14477, #16095 and the LUCENE-2843 discussion (#3917). Closest item: open PR #14477 (vsop-479, 'Use Arrays#mismatch in FSTEnum#rewindPrefix', a performance change, created 2025-04-12, last update 2026-06-05) rewrites the same loop but keeps 'getCurrentLabel() - getTargetLabel()' and never mentions overflow. Open PR #12688 copies the same compare into PrimitiveLongFSTEnum (byte labels only). Not fixed anywhere: upstream main 1c2072f0284 still has the line.
- Lucene commit tested
- main
5adff78bb168(12.0.0-SNAPSHOT), OpenJDK 25.0.3 on Linux aarch64: this doc's gradle runs (the repro without the fix, assertions on and off: fail; with fix A: pass; 188 selected tests of the closest suites with fix A: 0 failures, 43 skipped) and the hunt's runs; release jars 3.3.0, 3.6.2, 4.0.0, 8.11.4, 9.12.3, 10.5.2 and 10.6.0 (all fail) and 3.2.0 (no FST package). The cross-model review ran onea57360912ed, whoseFSTEnum.javaandIntsRefFSTEnum.javaare byte-identical to5adff78bb168, and was re-run on5adff78bb168.
- Finding record
lucene/core/src/java/org/apache/lucene/util/fst/FSTEnum.java:67-94 (at 5adff78bb168) (rewindPrefix; ':79 final int cmp = getCurrentLabel() - getTargetLabel();'; cmp < 0 breaks at :80-83, the reset to the first arc is at :84-89; callers at :136, :328, :653), lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java:59-73 (seekCeil 'Seeks to smallest term that's >= target', seekFloor 'Seeks to biggest term that's <= target'), :91-97 (getTargetLabel returns FST.END_LABEL once the target is used up), lucene/core/src/java/org/apache/lucene/util/fst/FST.java:68-73 (INPUT_TYPE 'Specifies allowed range of each int input label'), lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:580-590 (writeLabel: BYTE4 asserts only v >= 0)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/fst/FSTEnum.java:66-94, (at 5adff78bb168) :136, :139-161, :274-313 (doSeekCeilList; pushFirst at :286-288), :328, :653lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java:59-73, (at 5adff78bb168) :91-97lucene/core/src/java/org/apache/lucene/util/fst/FST.java:139 (at 5adff78bb168) (END_LABEL = -1); FSTCompiler.java:580-590 (writeLabel)lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:581, (at 5adff78bb168) :1109 (label asserts); IntsRefFSTEnum.java:96 (target ints returned unchecked)
- Who is affected
lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/SynonymMap.java:244, (at 5adff78bb168) .../miscellaneous/StemmerOverrideFilter.java:214, .../hunspell/Dictionary.java:648, .../hunspell/WordFormGenerator.java:68-71lucene/core/src/java/org/apache/lucene/util/fst/FST.java:62, (at 5adff78bb168) IntsRefFSTEnum.java:26 (@lucene.experimental)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java:59, (at 5adff78bb168) :67; package-info.java:30-32, :73; FST.java:68; FSTCompiler.java:253-255, :580-590; FSTEnum.java:84-89lucene/core/src/java/org/apache/lucene/util/IntsRef.java:106-107; (at 5adff78bb168) IntsRef.java:25 (@lucene.internal)lucene/test-framework/src/java/org/apache/lucene/tests/util/fst/FSTTester.java:420-520 (at 5adff78bb168)
- Suggested fix
lucene/codecs/src/test/org/apache/lucene/codecs/simpletext/TestSimpleTextPostingsFormat.java:24 (at 5adff78bb168) (@Nightly); lucene/core/src/java/org/apache/lucene/util/fst/FSTEnum.java:166, :196, :362, :393, :507, :529; FST.java:1047, :1068, :1085; Util.java:917
- Prior reports
https://github.com/apache/lucene/pull/14477, https://github.com/apache/lucene/pull/16095, https://github.com/apache/lucene/pull/12688, https://github.com/apache/lucene/issues/3917, https://github.com/apache/lucene/issues/6094
- Diagrams
lucene/core/src/java/org/apache/lucene/util/fst/FSTEnum.java:79-89; (at 5adff78bb168) IntsRefFSTEnum.java:91-97
- TestFSTEnumSeekAfterMaxLabel.java (lucene/core, package org.apache.lucene.util.fst, 1 test):
repro/LFV-1147/TestFSTEnumSeekAfterMaxLabel.java(38 lines)
The diagram illustrates the repro's FST ([3] -> 1, [3, MAX] -> 2), the reused enum on [3, MAX], the comparison at position 2 (MAX against END_LABEL -1, subtraction versus Integer.compare) and the two outcomes.
assets/LFV-1147/bug.png(500 KB)
![Diagram of the bug: a BYTE4 FST with keys [3] (output 1) and [3, 2147483647] (output 2). The node after label 3 is final and has one arc, 2147483647. An enum positioned on [3, 2147483647] seeks the ceiling of [3]. At position 2 the target has ended (label -1); the subtraction 2147483647 - (-1) overflows to -2147483648, the enum keeps the 2147483647 arc and returns [3, 2147483647] instead of [3].](../assets/LFV-1147/bug.png)