Outputs.skipFinalOutput is documented to skip what writeFinalOutput wrote by calling readFinalOutput, but its body calls skipOutput, the regular-output decoder. The two only agree while readFinalOutput is not overridden.
- An FST maps keys to outputs, for example
b -> 7. Outputs are split along the path: each arc carries a piece, and when a key is also a prefix of a longer key (aandab), the rest of its output is stored as a final output on the arc into its node. - The
Outputsclass turns these pieces into bytes:write/readfor arc outputs,writeFinalOutput/readFinalOutputfor final outputs (by default the same encoding), andskipOutput/skipFinalOutputto move past them (the defaults decode and discard; several built-ins skip the bytes without building an object). - A node with few arcs is stored as a plain list. A lookup scans the list and skips every arc before the one it wants, outputs included, so each skip must consume exactly the bytes that were written.
b in the root of a -> 5, ab -> 3, b -> 7: arc a's final output takes 8 bytes. The documented skip (readFinalOutput) passes all 8 and finds b; the inherited skipOutput consumes 1, reads the next 7 as a target and two bogus arcs, and takes arc a's target byte as a last-arc flag: null. Select the still to open it at full size. Generated with AI, checked against the code.- Write an
Outputs<Long>that delegates toPositiveIntOutputsbut stores final outputs as a fixed 8-byte long (writeFinalOutput/readFinalOutput). Keep the inheritedskipFinalOutput: its javadoc says it callsreadFinalOutput. - Build an FST from
a -> 5,ab -> 3,b -> 7with the defaultFSTCompiler.Builder. You expectUtil.get(fst, "b")to return 7; enumerating the FST does showb -> 7. - Instead it returns null, with assertions on and off, and
BytesRefFSTEnum.seekExact("b")misses too.aandabare found. - Why: the root's two arcs are stored as a plain list, so the lookup skips arc
aand its 8-byte final output withskipFinalOutput. The inherited body uses the regular decoder and consumes 1 byte; the scan reads the other 7 as a target and two bogus arcs, hits a byte with the last-arc bit and gives up beforeb.
Only application code with its own Outputs subclass whose final-output encoding differs in length from its regular one, keeping the documented default. Then a lookup that scans a small node past a key that is a prefix of a longer key can miss, silently. No Lucene FST is affected; Outputs is @lucene.experimental. Releases 9.9.0 to 10.6.0 and main.
Mechanism, terms and numbers575 words
Outputs lets a subclass encode final outputs (the output of a key that is also a prefix of a longer key) differently from regular arc outputs: writeFinalOutput and readFinalOutput are public and default to write / read. skipFinalOutput is documented as "Skip the output previously written with writeFinalOutput; defaults to just calling readFinalOutput and discarding the result" (Outputs.java:76-79 at 5adff78bb168), but its body is skipOutput(in) (:80-82), which decodes with read. The list-node scan in FST.findTargetArc skips the final output of every arc it passes with skipFinalOutput (FST.java:1113). For a subclass whose final encoding has a different length, the scan skips the wrong number of bytes, reads the rest of the node out of step and gives up: Util.get and BytesRefFSTEnum.seekExact return null for a key that is in the FST, with no error, with assertions on and off. Three keys and the default FSTCompiler.Builder are enough. No Lucene FST is affected: the only in-tree Outputs with its own final encoding, ListOfOutputs, overrides skipFinalOutput. The mismatch dates from LUCENE-5670 (4.9.0); the silent lookup miss from #12692 (9.9.0), which moved findTargetArc's scan onto the skip methods.
- The FST. The root has two arcs,
aandb. Two arcs are below the fixed-length thresholds (5 arcs at depth <= 3, 10 deeper:FSTCompiler.java:77-87,:601-606), so the root is a list node. Arcacarries output 3 (common(5, 3)) and, becauseais itself a key, the final output 2 (5 - 3), whichFSTCompilerwrites withwriteFinalOutput(FSTCompiler.java:493): 8 bytes. Arcbcarries output 7. The root's 15 bytes in reading order are31 61 03 | 02 00 00 00 00 00 00 00 | 02 | 1f 62 07(arca: flags, label, output, final output, target; arcb: flags, label, output). - The right decoder.
readArcdecodes the final output withreadFinalOutput(FST.java:949-950), so enumeration consumes 8 bytes and lands on arcb. - The scan.
findTargetArc('b')on a list node (FST.java:1093-1119) reads arca's flags and label, seesa < b, and skips the arc: its output withskipOutput(:1110), its final output withskipFinalOutput(:1113), its target withreadUnpackedNodeTarget(:1116). - The wrong skip. The inherited
skipFinalOutputisskipOutput(in), which isread(in),PositiveIntOutputs' VLong: it consumes 1 of the 8 bytes. - Out of step. Flags 0x31 have neither the stop-node nor the target-next bit, so
readUnpackedNodeTarget(:1116, a VLong) reads the next zero byte as arca's target, and the remaining six zero bytes form two bogus arcs (flags 0, label 0, target 0 each). Then the scan reads arca's real target byte02as flags and1fas the label. 0x02 is the last-arc bit, so it returns null (:1106-1107) without reachingb: 7 bytes misread, 1 as a target and 6 as two arcs.
The same inherited skip runs in readLastTargetArc (FST.java:664) and seekToNextNode (:1134). With a deeper FST (xa=5, xab=3, xb=7), a history probe saw seekFloor("y") throw ArrayIndexOutOfBoundsException in FST.readLastTargetArc (4.9.0, 9.8.0) or loop in FSTEnum.pushLast until a 1 GB heap ran out (9.12.3, 10.6.0, main). That failure is loud, on a different FST and call, and is not the claimed repro, which fails silently.
Exposure and likelihood206 words
- Who is hit. Code that subclasses
Outputs(public,@lucene.experimental,Outputs.java:31), overrideswriteFinalOutput/readFinalOutputwith an encoding whose length differs fromwrite, and keeps the inheritedskipFinalOutput. Then any lookup that scans a list node (a node below the fixed-length thresholds, as here) past an arc carrying a final output (a key that is a prefix of a longer key) reads the rest of the node out of step and can miss its key, as in the repro. DefaultFSTCompilersettings and three keys are enough. - Silent.
Util.getandseekExactreturn null for keys that are in the FST, with assertions on and off; enumeration withnext(),seekCeilandseekFloorstill show the key on the repro's FST, so tests that only iterate the FST pass. - Narrow reach. No in-tree FST is affected:
ListOfOutputs(misc), the only in-treeOutputswith its own final encoding, overridesskipFinalOutput;FSTOrdsOutputs(codecs) overrides it with the same body and no final-encoding change; every other built-in keepswriteFinalOutput == write. A GitHub code search forwriteFinalOutputoutside apache/lucene: the first 100 of its 249 hits are all copies of Lucene's own classes, with no third-party subclass among them (the other 149 were not inspected).
One JUnit test method on the public API: an Outputs<Long> with an 8-byte final-output encoding, three keys, the default builder, one Util.get. 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 java.io.IOException;
import org.apache.lucene.store.DataInput;
import org.apache.lucene.store.DataOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.IntsRefBuilder;
public class TestSkipFinalOutputDefault extends LuceneTestCase {
public void testLookupPastArcWithFinalOutput() throws IOException {
PositiveIntOutputs p = PositiveIntOutputs.getSingleton();
Outputs<Long> outputs =
new Outputs<>() {
@Override
public void writeFinalOutput(Long output, DataOutput out) throws IOException {
out.writeLong(output);
}
@Override
public Long readFinalOutput(DataInput in) throws IOException {
return in.readLong();
}
@Override
public Long common(Long a, Long b) {
return p.common(a, b);
}
@Override
public Long subtract(Long a, Long b) {
return p.subtract(a, b);
}
@Override
public Long add(Long a, Long b) {
return p.add(a, b);
}
@Override
public void write(Long output, DataOutput out) throws IOException {
p.write(output, out);
}
@Override
public Long read(DataInput in) throws IOException {
return p.read(in);
}
@Override
public Long getNoOutput() {
return p.getNoOutput();
}
@Override
public String outputToString(Long output) {
return p.outputToString(output);
}
@Override
public long ramBytesUsed(Long output) {
return p.ramBytesUsed(output);
}
};
FSTCompiler<Long> compiler = new FSTCompiler.Builder<>(FST.INPUT_TYPE.BYTE1, outputs).build();
IntsRefBuilder scratch = new IntsRefBuilder();
compiler.add(Util.toIntsRef(new BytesRef("a"), scratch), 5L);
compiler.add(Util.toIntsRef(new BytesRef("ab"), scratch), 3L);
compiler.add(Util.toIntsRef(new BytesRef("b"), scratch), 7L);
FST<Long> fst = FST.fromFSTReader(compiler.compile(), compiler.getFSTReader());
// expected 7; actual null: skipFinalOutput's default calls skipOutput, not readFinalOutput
assertEquals(Long.valueOf(7), Util.get(fst, new BytesRef("b")));
}
}
cp $REPRO/TestSkipFinalOutputDefault.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestSkipFinalOutputDefault -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false (the production setting): the same failure
- Expected
Util.get(fst, "b")returns 7.- Observed
expected:<7> but was:<null>, with assertions on and off.
More files, environment, full output278 words
TestSkipFinalOutputDefault (one method, extends LuceneTestCase, package org.apache.lucene.util.fst of the lucene/core tests). The anonymous Outputs<Long> delegates every method to PositiveIntOutputs except writeFinalOutput / readFinalOutput (writeLong / readLong) and inherits skipFinalOutput. It builds a -> 5, ab -> 3, b -> 7 with new FSTCompiler.Builder<>(BYTE1, outputs).build() and asserts Util.get(fst, "b") == 7.
The test uses no randomness, so the seed does not matter (it failed with DEADBEEF, 5EED1701, 5EED1702 and random seeds). Before 9.10 only the FST-construction line changes, to compiler.compile(), because FST.fromFSTReader is 9.10+ API; that variant fails on 9.9.0 and 9.9.2 and passes on 9.8.0. As the regression test, the same method fits into TestFSTs or TestUtil as is.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.9.0, 9.9.2, 9.10.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0: fail; 9.8.0: clean |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (nothing platform-specific) |
cp $REPRO/TestSkipFinalOutputDefault.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestSkipFinalOutputDefault -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false (the production setting)
# -Ptests.asserts=false (the same with assertions on)
TestSkipFinalOutputDefault > testLookupPastArcWithFinalOutput FAILED
java.lang.AssertionError: expected:<7> but was:<null>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:A03750B16BECD232]:0)
...
at org.apache.lucene.util.fst.TestSkipFinalOutputDefault.testLookupPastArcWithFinalOutput(TestSkipFinalOutputDefault.java:89)
# byte probe on main, assertions off (evidence, not the repro; labels in decimal: 97 = 'a', 31 = byte 0x1f)
arc 'a': output=3 finalOutput=2 flags=0x31 isLast=false
arc 'b': output=7 finalOutput=0 flags=0x1f isLast=true
arc 'a' flags=0x31: output bytes=1, final output: skipFinalOutput consumes 1 byte(s), readFinalOutput consumes 8 byte(s) (value 2)
scan step 1: flags=0x31 label=97 ('a') -> skip this arc
scan step 2: flags=0x0 label=0 -> skip this arc
scan step 3: flags=0x0 label=0 -> skip this arc
scan step 4: flags=0x2 label=31 -> last-arc flag set: return null
Util.get(b) = null- The method's own javadoc: "Skip the output previously written with writeFinalOutput; defaults to just calling readFinalOutput". Both final-output methods are public and documented as overridable.
- Nothing documents an "override all three" rule, and no test asserts the current default.
- The fast skip the body gives the built-ins does not need the default: each built-in can override
skipFinalOutputitself.
The full argument117 words
- Contract.
skipFinalOutput's javadoc namesreadFinalOutputas the default and calls itself the counterpart ofwriteFinalOutput(Outputs.java:76-79).writeFinalOutputandreadFinalOutputare public, non-final and documented as defaults (:52-58,:68-74). No javadoc, package doc,MIGRATE.mdorCHANGES.txtentry says that overriding them also requires overridingskipFinalOutput. - Tests. No test in
lucene/*/src/testor the test framework mentionsskipFinalOutput. - History. LUCENE-5670 (#6732, 2014) added the javadoc and the body in one commit; its 11 comments discuss only
skipOutputandDataInput.skipBytes. #12692 (9.9.0) movedfindTargetArc's scan fromreadNextRealArc, which decodes withreadFinalOutput, to the skip methods; its review threads discussed only arc flags. Nobody discussed what the default should call.
The devil's-advocate check138 words
The argument. The skip-without-decode body is a deliberate optimisation for the built-ins that override only skipOutput, and only the javadoc is wrong: the contributor's patch had the body without javadoc (the committer wrote it), the same commit gave ListOfOutputs a paired override, the API is experimental, and no report of it was found in 12 years.
Why it fails. A subclass that follows the method's documentation gets wrong lookups, silently, and no document states the rule it broke. The optimisation does not need the default: each built-in can override skipFinalOutput itself, as FSTOrdsOutputs (LUCENE-5819, 2014) already does with exactly the default body, which suggests the documented default was the authors' mental model (an interpretation).
What survives. Keeping the body and fixing the javadoc (option B) is a legitimate API-contract choice, and a maintainer may well prefer it.
Recommended A+. Default calls readFinalOutput, built-ins keep their fast skip (recommended)
Make the default call readFinalOutput(in), as its javadoc says, and give the five built-ins that override only skipOutput a skipFinalOutput that calls skipOutput, so their scan is unchanged. The repro passes with assertions on and off, and 374 FST, codec and suggest tests pass.
// Outputs.java: the default does what its javadoc says
public void skipFinalOutput(DataInput in) throws IOException {
- skipOutput(in);
+ readFinalOutput(in);
}
// ByteSequenceOutputs, CharSequenceOutputs, IntSequenceOutputs, PairOutputs, FSTTermOutputs:
// keep skipping without decoding (their final encoding is their regular one)
+ @Override
+ public void skipFinalOutput(DataInput in) throws IOException {
+ skipOutput(in);
+ }All options and trade-offs3 options ยท 471 words
Option A+ makes the documented contract true while every built-in keeps the skip code it runs today (no slowdown expected; not benchmarked). Option A is the same default change alone; option B keeps the code and documents the rule instead.
A+. Default calls readFinalOutput, built-ins keep their fast skip (recommended) (validated by the checks)
Outputs.skipFinalOutput calls readFinalOutput(in). ByteSequenceOutputs, CharSequenceOutputs, IntSequenceOutputs, PairOutputs and FSTTermOutputs (codecs) get skipFinalOutput overrides that call skipOutput(in), which is what they run today, since their final encoding is their regular one. PositiveIntOutputs, NoOutputs and UpToTwoPositiveIntOutputs do not override skipOutput, so they behave the same either way; ListOfOutputs and FSTOrdsOutputs already override skipFinalOutput. 6 files, +26/-1.
// Outputs.java: the default does what its javadoc says
public void skipFinalOutput(DataInput in) throws IOException {
- skipOutput(in);
+ readFinalOutput(in);
}
// ByteSequenceOutputs, CharSequenceOutputs, IntSequenceOutputs, PairOutputs, FSTTermOutputs:
// keep skipping without decoding (their final encoding is their regular one)
+ @Override
+ public void skipFinalOutput(DataInput in) throws IOException {
+ skipOutput(in);
+ }Trade-offs
The built-ins keep the skip code they run today: no behaviour change, and no slowdown expected (not benchmarked). A custom subclass that relies on the default now decodes its final output when skipping: correct, and it may override skipFinalOutput for speed, as before. It is not a universal fix for subclasses of an optimised built-in: a subclass of PairOutputs (public, not final) or of FSTTermOutputs (not final, package-private) that changes the final encoding inherits the new skipOutput-based override and must override skipFinalOutput itself, the same rule as today.
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),:lucene:misc:test --tests 'org.apache.lucene.misc.util.fst.*'(TestFSTsMisc, 3),:lucene:codecs:testforcodecs.memory.*andcodecs.blocktreeords.*(3 classes, 97) and:lucene:suggest:test(31 classes, 236): 374 tests, 18 skipped, 0 failures;checkGoogleJavaFormatpasses for core and codecs.
The one-line change to Outputs.skipFinalOutput, without the built-in overrides.
public void skipFinalOutput(DataInput in) throws IOException {
- skipOutput(in);
+ readFinalOutput(in);
}Trade-offs
Correct for everyone, but the five built-ins above would then decode, and usually allocate an object for, every final output the scan passes instead of skipping it, which partly undoes #12692's purpose. Not measured.
What the checks validated
By the hunt: the repro passes with assertions on and off, and TestFSTs plus fst.TestUtil (25 tests) pass; the cross-model reviewer ran TestFSTs (20) and TestFSTsMisc (3) with it (pass).
Keep skipOutput(in) and document that a subclass overriding writeFinalOutput / readFinalOutput must also override skipFinalOutput.
Trade-offs
No code change, but the trap stays silent for anyone who misses the sentence. Not run.
Downstream code that builds FSTs with such a custom Outputs (its own dictionaries, maps or suggesters) would silently get "not found" for keys that are present; stock Lucene FSTs and Lucene's own modules are unaffected.
A proof-obligation audit of the documented defaults of Outputs: each default must do what its javadoc says. skipFinalOutput says it calls readFinalOutput but calls skipOutput, and the two agree only while readFinalOutput is not overridden. A one-method public-API JUnit test then witnessed the missed lookup.
The method in full108 words
The audit took each overridable default of Outputs and checked its body against its javadoc and its callers. For skipFinalOutput the obligation "skip what writeFinalOutput wrote" holds only if writeFinalOutput and write produce the same number of bytes, which the class does not require. The callers in FST showed where a mismatch is read: the list-node scans.
An executable repro on the public API 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 the silent miss in 9.9.0 and the mismatch in 4.9.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-03). skipFinalOutput appears in no issue, PR or list mail. Nearest: #6732 (LUCENE-5670, which added it) and #12692.
Searches3 searches
Hits were history only: #6732 (LUCENE-5670, added skipFinalOutput; discussion of skipOutput and skipBytes only), #12692 (moved findTargetArc's scan onto it; review about arc flags), #6881 (LUCENE-5819, FSTOrdsOutputs), #6646 (LUCENE-5584, output reuse) and #5470 (ListOfOutputs). Upstream main 1c2072f0284 (2026-11-02) still has skipOutput(in); no open PR touches Outputs.java.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-03; re-checked the same day | three scripted prior-report searches (153 queries: identifiers, the symptom, the history) and 26 manual searches incl. apache/lucene-solr, Elasticsearch, OpenSearch, Solr and Lucene.NET | no report; skipFinalOutput has 0 hits in any issue or PR |
| Open and recent PRs | 2026-11-03 | the changed files of all 409 open apache/lucene PRs and of the PRs updated since 2026-11-01 | none touches Outputs.java or an Outputs file |
| Mailing lists, JIRA, web | 2026-11-03 | dev@, java-user@, issues@ and commits@ queries; LUCENE and SOLR JIRA; GitHub code search; 5 web searches | only commit mails and hosted javadoc copies |
The javadoc/body mismatch came with LUCENE-5670 (4.9.0, 2014). The silent Util.get miss came with #12692 (9.9.0, 2023), which moved findTargetArc's scan onto the skip methods; 9.8.0 is clean. Every release run since fails, through 10.6.0 and main. Not fixed upstream.
Versions tested6 rows
86e7b083816 (trunk) / 6574202cef5 (branch_4x), LUCENE-5670 (#6732, Christian Ziech, committed by mikemccand, 2014-05-20) added skipFinalOutput with this javadoc and this body, and switched readLastTargetArc and seekToNextNode from readFinalOutput to it. 343a9e71004 / 6fc3483e4fa, #12692 "Avoid object construction when linear searching arcs" (gf2121, reviewed by mikemccand, merged 2023-10-19), moved findTargetArc's list-node scan from readNextRealArc (which decodes with readFinalOutput) to skipOutput / skipFinalOutput. On main's history, git log -S skipFinalOutput finds only these commits and 80fe3c277f3 (LUCENE-5819; --all adds branch copies of these changes); nothing ever changed the default or its javadoc.
Release jars plus single-file swaps; no intermediate commits were built. The release tags not listed below were checked by git tag --contains.
| 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.9.0, 9.9.2, 9.10.0, 9.12.3, 10.0.0, 10.5.2 | affected | fail: expected:<7> but was:<null> (9.9.x through the pre-9.10 one-line variant) |
| 9.8.0 + post-#12692 FST.java | line isolation | fails; 9.9.0 with the pre-#12692 FST.java passes |
| 9.8.0 | not affected | passes |
| 4.9.0 to main, seekFloor probe | other shape | a deeper FST: ArrayIndexOutOfBoundsException (4.9.0, 9.8.0) or OutOfMemoryError (9.12.3, 10.6.0, main); 4.8.1 and 4.9.0 with the default fixed pass |
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 verdicts237 words
The cross-model review ran on main ea57360912ed, whose Outputs.java and FST.java are identical to 5adff78bb168, and was re-run on 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, with TestFSTs and TestUtil (25 tests). |
| independent reproducer | real | Own test (tag byte plus VLong final encoding; x=10, xy=4, z=1; Util.get(z) null instead of 1) fails on main, 10.6.0, 10.5.2, 9.12.3, 9.9.0 and 9.9.2; 9.8.0 passes. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own marker-byte test: Util.get and seekExact miss, the matching-skip control passes; fix A passes it, TestFSTs (20) and TestFSTsMisc (3). The "intended" argument rated moderate. |
| devil's-advocate verifier | real | Steelmanned the optimisation reading and rebutted it; probe: get(b) and seekExact(b) null, next, seekCeil and seekFloor correct, all correct once skipFinalOutput is overridden. |
| adjudication, separate re-runs, audit and final repro check | confirmed, Low | The adjudication re-ran the independent test (seed C0FFEE) and a separate re-run repeated the hunt's test (seeds 5EED1701, 5EED1702): they fail on main and pass with fix A; one minimal deterministic test. |
| this doc | reproduced; fix A+ validated | Gradle on a scratch worktree at 5adff78bb168: the repro fails without the fix and passes with A+; 374 tests pass with A+. A byte probe confirmed the 1-byte versus 8-byte skip. |
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 509 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,364 words
- Registry title
- Outputs.skipFinalOutput's default calls skipOutput, not readFinalOutput as its javadoc says, so an Outputs subclass with its own final-output encoding makes Util.get and FSTEnum.seekExact silently miss keys that are in the FST
- Area
- util / FST (Outputs.skipFinalOutput default; FST.findTargetArc list-node scan)
- Priority
- Low, rank 928: An application Outputs subclass that has its own final-output encoding but keeps the documented skipFinalOutput default makes Util.get and seekExact silently miss keys that are in the FST, in every release from 9.9.0 through 10.6.0 and on main. It needs a custom subclass of an experimental extension point that no in-tree FST uses and for which no outside example was found, so its reach is very narrow.
- Impact class
- minor
- Impact note
- Only application code that writes its own Outputs subclass (a public, @lucene.experimental extension point) whose writeFinalOutput/readFinalOutput encoding differs from write/read, and that keeps the documented skipFinalOutput default, is hit: Util.get and BytesRefFSTEnum.seekExact return null for a key that is in the FST, with no error and with -ea and -da, whenever the lookup scans a list-encoded node (the encoding FSTCompiler uses for nodes with few arcs) past an arc that carries a final output; default FSTCompiler settings, three keys, no scale needed; no in-tree FST is affected (ListOfOutputs, the only in-tree Outputs with its own final-output encoding, overrides skipFinalOutput); releases 9.9.0 to 10.6.0 and main for this path, the javadoc/body mismatch itself since 4.9.0. The final repro check set the impact class to minor (was: silent wrong results): the wrong result is silent but only reachable through a custom experimental Outputs subclass.
- Summary
- You write an Outputs<Long> that delegates everything to PositiveIntOutputs except writeFinalOutput and readFinalOutput, which store final outputs as a fixed 8-byte long, and you keep skipFinalOutput, whose javadoc says it 'defaults to just calling readFinalOutput and discarding the result'; you build an FST from a=5, ab=3, b=7 with the default FSTCompiler.Builder and call Util.get(fst, "b"). You expect 7, but get null, with or without assertions (BytesRefFSTEnum.seekExact("b") misses the same way). The default body actually calls skipOutput, the regular-output decoder, so the linear scan of the root list node in FST.findTargetArc skips one byte of arc a's 8-byte final output, loses its place in the node and gives up.
- Introduced
- 86e7b083816f942bd2c3e0124c4008819c734003 (LUCENE-5670, now #6732, 'FST should skip over outputs it is not interested in', Christian Ziech via mikemccand; branch_4x 6574202cef5; Subversion era, no pull request): added skipFinalOutput with a javadoc that names readFinalOutput as the default but a body that calls skipOutput, and switched the readLastTargetArc and seekToNextNode scans from readFinalOutput to it. The repro's silent Util.get/seekExact miss needs a second change, 343a9e7100477edd01c7851acc853e2a20ed56f4 (#12692 'Avoid object construction when linear searching arcs', gf2121, reviewed by mikemccand; branch_9x 6fc3483e4fa), which moved findTargetArc's list-node scan from readNextRealArc (decodes with readFinalOutput) to skipOutput/skipFinalOutput [read: git show and git log -S skipFinalOutput on the history clone; exec: the repro passes on 9.8.0 and fails on 9.9.0, and swapping only FST.java across #12692 flips the result in both directions; history probe on FSTEnum.seekFloor: 4.8.1 passes, 4.9.0 fails, 4.9.0 with only the default changed to readFinalOutput passes] (2014-05-20 (86e7b083816, LUCENE-5670, 4.9.0); 2023-10-20 +0800 = 2023-10-19T16:41Z (author date of 343a9e71004 and 6fc3483e4fa, #12692, merged 2023-10-19, 9.9.0))
- First affected version
- 9.9.0 for the repro's silent Util.get/seekExact miss [exec: 9.8.0 passes, 9.9.0 fails]; 4.9.0 for the javadoc/body mismatch, which already breaks FSTEnum.seekFloor for such a subclass with an exception [exec: history probe, 4.8.1 passes, 4.9.0 fails]
- Affected versions
- Util.get/seekExact path: every release from 9.9.0 through 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 cross-model review re-run, the intended-behaviour check, judge and the registration check), 10.6.0 -ea and -da, 10.5.2, 10.0.0, 9.12.3, 9.10.0, 9.9.2 and 9.9.0 (9.9.x through a pre-9.10 API variant that differs only in the compile line); 9.8.0 passes. The others by git tag --contains of 6fc3483e4fa (9.9.0-9.12.3) and 343a9e71004 (10.0.0-10.6.0) [read]. Mismatch since 4.9.0 [read]: a seekFloor history probe with the same kind of subclass throws ArrayIndexOutOfBoundsException in FST.readLastTargetArc on 4.9.0 and 9.8.0 and runs out of a 1 GB heap in FSTEnum.pushLast on 9.12.3, 10.6.0 and main [exec], and passes on 4.8.1. Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) still has skipOutput(in) at Outputs.java:81. Not new in 10.6. 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-03, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): three scripted prior-report searches (153 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive), 26 manual GitHub searches incl. apache/lucene-solr, Elasticsearch, OpenSearch, Solr and Lucene.NET, manual JIRA and list-archive queries, GitHub code search, 5 web searches, the changed files of all 409 open apache/lucene PRs and of the PRs updated since 2026-10-27 (none touches util/fst or an Outputs file), and full reads of #6732 (LUCENE-5670, which added skipFinalOutput; its 11 comments and attachments discuss only skipOutput and DataInput.skipBytes) and #12692 (the linear-scan change; its review discusses only arc flags). The string skipFinalOutput appears in no GitHub issue or PR on any repository, no LUCENE or SOLR JIRA ticket and no dev@, java-user@ or issues@ mail. Outputs.java is unchanged upstream since 2020-12-23 (author date, reformatting), so it is not fixed.
- 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+; 374 FST, misc FST, memory and blocktreeords codec and suggest tests pass with fix A+) and standalone JUnit runs; release jars 9.8.0 (pass), 9.9.0, 9.9.2, 9.10.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (fail); 9.8.0 and 9.9.0 with onlyFST.javaswapped across #12692. OpenJDK 25.0.3, Linux aarch64.
- Finding record
lucene/core/src/java/org/apache/lucene/util/fst/Outputs.java:28-33 (at 5adff78bb168) (@lucene.experimental), :52-58 (writeFinalOutput 'By default this just calls write'), :63-66 (skipOutput, default read), :68-74 (readFinalOutput 'By default this just calls read'), :76-79 (skipFinalOutput javadoc: 'Skip the output previously written with writeFinalOutput; defaults to just calling readFinalOutput and discarding the result'), :80-82 (body skipOutput(in))lucene/core/src/java/org/apache/lucene/util/fst/FST.java:1093-1119 (at 5adff78bb168) (findTargetArc linear scan; outputs.skipFinalOutput at :1113), :661-664 (readLastTargetArc), :1130-1134 (seekToNextNode), :949-950 (readArc decodes with readFinalOutput); lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:493 (writeFinalOutput)lucene/misc/src/java/org/apache/lucene/misc/util/fst/ListOfOutputs.java:102, (at 5adff78bb168) :126, :140 (writeFinalOutput, readFinalOutput, skipFinalOutput overrides); lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/FSTOrdsOutputs.java:190-193 (skipFinalOutput override with the default body; no final-output encoding change)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/fst/Outputs.java:52-58 (at 5adff78bb168) (writeFinalOutput, 'By default this just calls write'), :63-66 (skipOutput, default read), :68-74 (readFinalOutput, 'By default this just calls read'), :76-79 (skipFinalOutput javadoc 'Skip the output previously written with writeFinalOutput; defaults to just calling readFinalOutput and discarding the result.'), :80-82 (body skipOutput(in))lucene/core/src/java/org/apache/lucene/util/fst/FST.java:949-950 (at 5adff78bb168) (readArc: readFinalOutput), :1093-1119 (findTargetArc linear scan; skipOutput :1110, skipFinalOutput :1113, readUnpackedNodeTarget :1116, isLast return null :1106-1107), :683-685 (readUnpackedNodeTarget: readVLong), :80, :83 (BIT_TARGET_NEXT, BIT_STOP_NODE), :664 (readLastTargetArc), :1134 (seekToNextNode)lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:77-87 (at 5adff78bb168) (fixed-length-arc thresholds), :493 (writeFinalOutput), :601-606 (shouldExpandNodeWithFixedLengthArcs)
- Who is affected
lucene/core/src/java/org/apache/lucene/util/fst/Outputs.java:31 (at 5adff78bb168) (@lucene.experimental)lucene/misc/src/java/org/apache/lucene/misc/util/fst/ListOfOutputs.java:102, (at 5adff78bb168) :126, :140 (writeFinalOutput, readFinalOutput, skipFinalOutput overrides); lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/FSTOrdsOutputs.java:190-193 (skipFinalOutput override calling skipOutput)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/fst/Outputs.java:52-82 (at 5adff78bb168)
- Suggested fix
Lucene main 5adff78bb168: skipOutput overrides in lucene/core/src/java/org/apache/lucene/util/fst/ByteSequenceOutputs.java:135, CharSequenceOutputs.java:136, IntSequenceOutputs.java:135, PairOutputs.java:158 and lucene/codecs/src/java/org/apache/lucene/codecs/memory/FSTTermOutputs.java:253; no skipOutput override in PositiveIntOutputs, NoOutputs, lucene/misc UpToTwoPositiveIntOutputslucene/core/src/java/org/apache/lucene/util/fst/PairOutputs.java:29 (at 5adff78bb168) (public class, not final); lucene/codecs/src/java/org/apache/lucene/codecs/memory/FSTTermOutputs.java:37 (package-private class, not final)
- Prior reports
this doc's re-check, 2026-11-03: GitHub search 'skipFinalOutput' (0), 'readFinalOutput' (#6646, #6093); PRs updated since 2026-11-03 (11, none touches util/fst or an Outputs file); upstream main 1c2072f0284 Outputs.java:80-81 unchanged
- Diagrams
lucene/core/src/java/org/apache/lucene/util/fst/FST.java:1093-1119, (at 5adff78bb168) Outputs.java:76-82
- TestSkipFinalOutputDefault.java (lucene/core, package org.apache.lucene.util.fst, 1 test):
repro/LFV-1166/TestSkipFinalOutputDefault.java(91 lines)
The bytes, positions and lookup results are those printed by a probe on main with assertions off (FST.java:1093-1119, Outputs.java:76-82).
assets/LFV-1166/bug.png(509 KB)
