All findings · Lucene finding · LFV-1166 · outputs-skipfinaloutput-default-skips-regular-output

Outputs.skipFinalOutput's default skips a final output with the regular-output decoder, so FST lookups miss keys for an Outputs with its own final-output encoding

An FST Outputs with its own final-output format that keeps the inherited skipFinalOutput makes lookups miss keys: with a -> 5, ab -> 3, b -> 7, Util.get(fst, "b") returns null instead of 7. The default skips with the regular-output decoder, not with readFinalOutput as its javadoc says.

Summary

Priority
Low, rank 928An 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
minor
Since
9.9.0 (#12692, 2023); the javadoc/body mismatch since 4.9.0 (2014)introduced 2014-05-20 · over 12 years
Affected
9.9.0 to 10.6.0 and main (7 releases run, all fail; 9.8.0 clean); not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of Outputs' documented defaults, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: outputs in an FST

  1. 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 (a and ab), the rest of its output is stored as a final output on the arc into its node.
  2. The Outputs class turns these pieces into bytes: write / read for arc outputs, writeFinalOutput / readFinalOutput for final outputs (by default the same encoding), and skipOutput / skipFinalOutput to move past them (the defaults decode and discard; several built-ins skip the bytes without building an object).
  3. 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.
Diagram of the bug: the root node of an FST built from a -> 5, ab -> 3, b -> 7 with a custom Outputs that writes final outputs as 8-byte longs. Arc a holds flags, label, output 3, the 8-byte final output 2 and a target byte; arc b follows with output 7. A lookup of b scans past arc a. Skipping the final output with readFinalOutput consumes 8 bytes and reaches arc b. The inherited skipFinalOutput calls skipOutput, which consumes 1 byte, so the scan reads the next 7 bytes as arc a's target and two bogus arcs, reads arc a's target byte 0x02 as flags with the last-arc bit set, and returns null: Util.get(fst, "b") is null instead of 7.
Looking up 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.

The finding, step by step

  1. Write an Outputs<Long> that delegates to PositiveIntOutputs but stores final outputs as a fixed 8-byte long (writeFinalOutput / readFinalOutput). Keep the inherited skipFinalOutput: its javadoc says it calls readFinalOutput.
  2. Build an FST from a -> 5, ab -> 3, b -> 7 with the default FSTCompiler.Builder. You expect Util.get(fst, "b") to return 7; enumerating the FST does show b -> 7.
  3. Instead it returns null, with assertions on and off, and BytesRefFSTEnum.seekExact("b") misses too. a and ab are found.
  4. Why: the root's two arcs are stored as a plain list, so the lookup skips arc a and its 8-byte final output with skipFinalOutput. 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 before b.

Who is affected

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 bug, step by step (main 5adff78bb168)

  1. The FST. The root has two arcs, a and b. 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. Arc a carries output 3 (common(5, 3)) and, because a is itself a key, the final output 2 (5 - 3), which FSTCompiler writes with writeFinalOutput (FSTCompiler.java:493): 8 bytes. Arc b carries output 7. The root's 15 bytes in reading order are 31 61 03 | 02 00 00 00 00 00 00 00 | 02 | 1f 62 07 (arc a: flags, label, output, final output, target; arc b: flags, label, output).
  2. The right decoder. readArc decodes the final output with readFinalOutput (FST.java:949-950), so enumeration consumes 8 bytes and lands on arc b.
  3. The scan. findTargetArc('b') on a list node (FST.java:1093-1119) reads arc a's flags and label, sees a < b, and skips the arc: its output with skipOutput (:1110), its final output with skipFinalOutput (:1113), its target with readUnpackedNodeTarget (:1116).
  4. The wrong skip. The inherited skipFinalOutput is skipOutput(in), which is read(in), PositiveIntOutputs' VLong: it consumes 1 of the 8 bytes.
  5. 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 arc a's target, and the remaining six zero bytes form two bogus arcs (flags 0, label 0, target 0 each). Then the scan reads arc a's real target byte 02 as flags and 1f as the label. 0x02 is the last-arc bit, so it returns null (:1106-1107) without reaching b: 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), overrides writeFinalOutput / readFinalOutput with an encoding whose length differs from write, and keeps the inherited skipFinalOutput. 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. Default FSTCompiler settings and three keys are enough.
  • Silent. Util.get and seekExact return null for keys that are in the FST, with assertions on and off; enumeration with next(), seekCeil and seekFloor still 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-tree Outputs with its own final encoding, overrides skipFinalOutput; FSTOrdsOutputs (codecs) overrides it with the same body and no final-encoding change; every other built-in keeps writeFinalOutput == write. A GitHub code search for writeFinalOutput outside 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).

Minimal repro

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.

TestSkipFinalOutputDefault.java (lucene/core, package org.apache.lucene.util.fst, 1 test)91 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.util.fst;

import 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")));
  }
}
Command
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.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3
osLinux aarch64 (nothing platform-specific)
Command (environment)
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)

Observed output

# -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

Why it looks unintended

  • 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 skipFinalOutput itself.
The full argument117 words
  • Contract. skipFinalOutput's javadoc names readFinalOutput as the default and calls itself the counterpart of writeFinalOutput (Outputs.java:76-79). writeFinalOutput and readFinalOutput are public, non-final and documented as defaults (:52-58, :68-74). No javadoc, package doc, MIGRATE.md or CHANGES.txt entry says that overriding them also requires overriding skipFinalOutput.
  • Tests. No test in lucene/*/src/test or the test framework mentions skipFinalOutput.
  • History. LUCENE-5670 (#6732, 2014) added the javadoc and the body in one commit; its 11 comments discuss only skipOutput and DataInput.skipBytes. #12692 (9.9.0) moved findTargetArc's scan from readNextRealArc, which decodes with readFinalOutput, 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.

Suggested fix

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, seed DEADBEEF: the repro fails without the fix (assertions on, and -Ptests.asserts=false) and passes with it (both).
  • With the fix: :lucene:core:test --tests 'org.apache.lucene.util.fst.*' (9 classes, 38 tests), :lucene:misc:test --tests 'org.apache.lucene.misc.util.fst.*' (TestFSTsMisc, 3), :lucene:codecs:test for codecs.memory.* and codecs.blocktreeords.* (3 classes, 97) and :lucene:suggest:test (31 classes, 236): 374 tests, 18 skipped, 0 failures; checkGoogleJavaFormat passes for core and codecs.

A. Default calls readFinalOutput, nothing else (validated by the checks)

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).

B. Fix the javadoc only

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 note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits (incl. the JIRA archive), the mailing lists, downstream trackers or the web (searched 2026-11-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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-03; re-checked the same daythree scripted prior-report searches (153 queries: identifiers, the symptom, the history) and 26 manual searches incl. apache/lucene-solr, Elasticsearch, OpenSearch, Solr and Lucene.NETno report; skipFinalOutput has 0 hits in any issue or PR
Open and recent PRs2026-11-03the changed files of all 409 open apache/lucene PRs and of the PRs updated since 2026-11-01none touches Outputs.java or an Outputs file
Mailing lists, JIRA, web2026-11-03dev@, java-user@, issues@ and commits@ queries; LUCENE and SOLR JIRA; GitHub code search; 5 web searchesonly commit mails and hosted javadoc copies

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off; passes with fix A+
10.6.0 affectedfails with assertions on and off
9.9.0, 9.9.2, 9.10.0, 9.12.3, 10.0.0, 10.5.2 affectedfail: expected:<7> but was:<null> (9.9.x through the pre-9.10 one-line variant)
9.8.0 + post-#12692 FST.java line isolationfails; 9.9.0 with the pre-#12692 FST.java passes
9.8.0 not affectedpasses
4.9.0 to main, seekFloor probe other shapea 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

Independent checks

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

Checks and 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.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedThe test fails on main (assertions on and off), 10.6.0 and 9.12.3; fix A passes, with TestFSTs and TestUtil (25 tests).
independent reproducer realOwn 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 realSteelmanned 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, LowThe 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+ validatedGradle 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.

Diagram file

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

Sources and provenance

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

Finding record

LFV-1166, snapshot of 2026-11-06

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 only FST.java swapped across #12692. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

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 UpToTwoPositiveIntOutputs
  • lucene/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

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

Repro files

About the diagrams

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).

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.