All findings · Lucene finding · LFV-487 · blocktreeords-termstate-seek-after-ord-seek-keeps-stale-frames

BlockTreeOrds TermsEnum: a same-term TermState seek right after seekExact(ord) makes later seeks miss existing terms

On a field written with the opt-in BlockTreeOrdsPostingsFormat, re-seeking a TermsEnum to its current term with its own termState() right after seekExact(ord) breaks later seeks: with 26 terms a00..a24 and b, seekExact("b") returns false. It has been there since 4.10.0 and still fails on 10.6.0 and main.

Summary

Priority
Low, rank 599Sibling of LFV-157 (a TermState seek leaves the block-tree enum in the wrong position), but in the opt-in BlockTreeOrds codec and only after a redundant ord-seek followed by a same-term TermState call.
Impact
silent wrong results
Since
4.10.0 (LUCENE-5819, committed 2014-07-20)introduced 2014-07-20 · over 12 years
Affected
4.10.0 to 10.6.0 and main (10 releases run); only the opt-in BlockTreeOrds format
Status
documented
Found by
a bounded-exhaustive differential check and an exhaustive call-sequence model, then a JUnit

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

seekExact(ord) loads only the target term's block and leaves the enum's positioned flag false, because the frames above that block are missing. The TermState seek sees it is already on that term, skips all work, and sets positioned to true anyway. The next seek trusts the never-loaded root frame and concludes that b is not there.

Background: how a block-tree TermsEnum keeps its place

  1. A TermsEnum walks the sorted terms of one field. Besides next() and seeking by term, it can save its place as a TermState (termState()) and jump back with seekExact(term, state); with an ord-capable format such as BlockTreeOrds it can also jump to the n-th term with seekExact(ord).
  2. The block-tree terms dictionary stores terms in blocks: terms that share a prefix go into their own block below the root block (the default sizes aim at 25 to 48 entries per block). To seek, the enum keeps one frame per block on the path from the root to the current term.
  3. BlockTreeOrds' enum also keeps a flag, positioned, which means "my frames are valid for the current term". When it is set, a seek reuses the frames instead of starting again from the root.
Summary of the bug: 26 terms a00..a24 and b with BlockTreeOrds; seekExact(0), then seekExact(term(), termState()) on the same enum, then seekExact("b"); expected true, actual false, because the ord seek loaded only block a and the TermState shortcut marked the enum positioned over the never-loaded root frame.
The minimal test step by step: the two blocks that hold the 26 terms, the enum's frames and its positioned flag after each call, why seekExact("b") returns false, the control and the fix. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document whose field f has 26 terms, a00 to a24 and b, with BlockTreeOrdsPostingsFormat and its default block sizes. The 25 a terms get their own block; the root block holds that block and b.
  2. On one TermsEnum, call seekExact(0): it lands on a00 and loads only the a block. Then call seekExact(term(), termState()) with the enum's own state, a redundant re-seek the TermsEnum javadoc allows.
  3. Call seekExact("b"). You expect true: b exists, and without the TermState call the same enum returns true.
  4. You get false, with no error, with assertions on and off. The TermState call did no work but marked the enum's frames as valid, so the seek reused the root block's frame, which was never loaded and looks empty.
  5. Same cause: seekCeil("b") throws EOFException (or, once the terms file is larger than about 7 KB, returns a made-up term read from the file's header), and next() after a24 throws.

Who is affected

Only applications that write a field with the opt-in BlockTreeOrdsPostingsFormat and drive the Expert TermsEnum API themselves: an ord seek, a same-term TermState seek, then a seek out of that term's block or next() past its end. No Lucene query, merge or CheckIndex path does this, and the default codec cannot. Where it happens, it fails every time.

Mechanism, terms and numbers1,038 words

BlockTreeOrdsPostingsFormat (module lucene-codecs, opt-in) stores term ordinals so that TermsEnum.ord() and TermsEnum.seekExact(long) work. Its seekExact(long) loads only the block that holds the target term and deliberately leaves the enum's internal positioned flag false, "because we didn't set frames for each byte leading up to current term". Its Expert seekExact(BytesRef, TermState) has a shortcut for a re-seek to the current term ("skip seek: already on target state") that does no work but sets positioned = true anyway. After seekExact(ord) and seekExact(term(), termState()) on the same enum, the next seekExact, seekCeil or next() therefore trusts parent frames that were never loaded: seekExact returns false for an existing term (assertions on and off), next() throws, and seekCeil throws on a tiny terms file or, on a larger one, returns a made-up term decoded from the terms file's header, whose postings can carry doc IDs beyond maxDoc. One document with 26 terms is enough. It has been in the code since the format was added (LUCENE-5819, first released in 4.10.0) and fails on every release run up to 10.6.0 and on main. The default codec cannot reach it (its seekExact(long) throws), and no Lucene query, merge or CheckIndex path makes the call sequence. A one-condition fix passes the test and the whole lucene/codecs test suite.

The mechanism (main 645d4fe14a20)

The 26 terms are stored as two blocks: a block for the prefix a with the 25 terms a00..a24, and the root block with two entries, that sub-block and the term b (default block sizes 25 and 48, OrdsBlockTreeTermsWriter.java:101, :107). An executed probe printed the enum's frames after each call. Steps 1-4 quote the probe; steps 5 and 6 are read from the code, and their exceptions and stack frames match the finder's executed 4-method test.

  1. A fresh enum loads nothing. The constructor sets currentFrame = staticFrame; the root push is commented out (OrdsSegmentTermsEnum.java:93-104).
  2. seekExact(0) loads only block a (:1094-1139). The reverse lookup in the terms index finds the block prefix a, so currentFrame = getFrame(0) (:1111) and pushFrame(arc, io.output, 1) (:1117) pushes and loads stack[1] (:1123-1129). stack[0], the root frame, is created with the frame constructor (OrdsSegmentTermsEnumFrame.java:105-110), which sets none of its position fields, and is never loaded. The method ends with the comment "Leave enum fully unpositioned, because we didn't set frames for each byte leading up to current term" and positioned = false (:1135-1138). Probe: positioned=false; stack[0] hasTerms=false fp=0 nextEnt=0 entCount=0; stack[1] fp=130 entCount=25.
  3. seekExact(term(), termState()) takes the shortcut (:1058-1080). Its condition target.compareTo(term.get()) != 0 || !termExists (:1064) is false, because the target is the current term and it exists, so the "skip seek: already on target state" branch (:1074-1078) does nothing, and positioned = true (:1079) runs anyway. Probe: positioned=true, frames unchanged.
  4. seekExact("b") trusts the stale frames. positioned && currentFrame != staticFrame (:256) holds, so it reuses them: a00 < b, so it continues from stack[0] (:326-334). The terms index has no arc for b, so it checks if (!currentFrame.hasTerms) (:420); the never-loaded root frame still has the default false, and the method returns false (:421-427). Probe: seekExact(b)=false, currentFrame.ord=0.
  5. seekCeil("b") reuses the frames the same way (:530). loadBlock treats the root frame as loaded, because nextEnt is 0, not -1 (OrdsSegmentTermsEnumFrame.java:164-167); the scan finds no entry (scanToTermNonLeaf returns END, OrdsSegmentTermsEnumFrame.java:699-704) and calls next() (OrdsSegmentTermsEnum.java:701), which calls loadNextFloorBlock (:946-948). That reads a block at file pointer 0 (OrdsSegmentTermsEnumFrame.java:137-146, :157-234): the start of the terms file, its codec header. A short file ends early (EOFException); a longer one is decoded as a block of terms. This header decoding is traced from the code; the probes observed only its results (the EOFException, and the made-up term below).
  6. next() after a24 re-seeks only when currentFrame == staticFrame || positioned == false (OrdsSegmentTermsEnum.java:932), so the catch-up its comment promises after a TermState seek is skipped. At the end of block a it pops to stack[0] (:961-962) and calls scanToSubBlock(130) (:964-969), which asserts fp=0 subFP=130 (OrdsSegmentTermsEnumFrame.java:557) or, with assertions off, reads an empty buffer (ArrayIndexOutOfBoundsException).

With the recommended fix, the same probe shows the TermState seek moving the enum to staticFrame, and seekExact("b") then seeks from the root: true, with the root frame loaded at fp=262 with 2 entries.

Other shapes (same cause)

  • Larger terms file: with the 26 terms plus 3,000 terms of another field (a 34,396-byte terms file), seekCeil("b") no longer throws. It returns NOT_FOUND on a 23-byte "term" made of header bytes (it contains the text OrdsBlockTreeTerms); its postings throw, or in another layout return doc IDs 13910 and 13933 in a segment with maxDoc 3000; further next() calls return 30 more byte strings cut from the terms file, then END.
  • An independent exhaustive test (k00..k39 in a sub-block plus m, n0, n1 in the root block; every start ord against every target): seekExact wrong for 120 of 1,849 (start ord, target) pairs, every one a false for an existing term (all 40 sub-block ords times the 3 root-block terms); seekCeil wrong for 400 of 2,279; next() wrong for 40 of 43 start ords.
  • The precise condition is an ancestor frame that the ord seek never loaded (here the root frame, the parent of the ord-sought block), which a later call then needs: a seek that has to leave the ord-sought block, or next() past its end. A follow-up inside that block does not fail (in the exhaustive test above, seekExact was wrong for exactly the pairs with a sub-block start and a root-block target). Nor does every enum history lead there: a reach probe found no failures after a single next() on a 2-level tree, but many after a fresh start, a prior seekExact(term), a prior seekExact(ord) and (on a 3-level tree) a full iteration; none without the TermState step and none with the fix.
Exposure and likelihood454 words
  • Who is exposed. Applications that write a field with BlockTreeOrdsPostingsFormat (chosen per field, for example through a per-field postings format; ord support is the reason to pick it) and that, on one enum, call seekExact(ord), then seekExact(sameTerm, its own termState()) with no other seek between (a redundant re-seek, for example from a "restore saved cursor" helper), then a seek that has to leave the ord-sought block or next() past its end. The ord-sought term must sit in a block below the root block. In the minimal shape, 25 terms that share the prefix a get such a block with the default sizes; in general the writer only aims at 25 to 48 entries per block and "in some cases the blocks may be smaller than the min" (OrdsBlockTreeTermsWriter.java:163-165), so the term count alone is not a threshold.
  • Who is not. The default codec: its block-tree enum throws UnsupportedOperationException from seekExact(long). Lucene itself: every in-tree TermState seek (TermQuery, SynonymQuery, PhraseQuery, MultiPhraseQuery, CombinedFieldQuery, SpanTermQuery, the multi-term query wrappers, and the sandbox PhraseWildcardQuery and TermAutomatonQuery) is in code that never calls seekExact(long), FuzzyTermsEnum and FilterLeafReader only delegate both calls, and CheckIndex does seekExact(ord) then seekCeil with no TermState seek.
  • Downstream (read-only survey). Elasticsearch and OpenSearch exclude the format in their test frameworks (@SuppressCodecs), and Solr does not reference it. neo4j switched its Lucene indexes to this format by default in a 2016 change (its PR #6537); a spot check of two of its current files found no trigger sequence, and whether it still uses the format was not determined.
  • Effect. Deterministic: the minimal repro fails on every run (seeds DEADBEEF, C0DEFACE and 5EED1234 five times, assertions on and off), and any follow-up that needs the never-loaded root frame fails every time; a follow-up that stays inside the ord-sought block does not. seekExact is silently wrong with assertions on and off; next() fails loudly; seekCeil fails loudly only on tiny terms files and otherwise returns a made-up term whose postings can carry doc IDs beyond maxDoc. Read side only, from the code: the enum only reads, so the index files are not changed, and nothing outside the segment is read (the stale frame reads the segment's terms file; the made-up term's postings are read from the segment's postings file, at offsets decoded from header bytes).
  • Scale. One document and 26 terms are enough.
  • How likely. Rare: no in-tree caller makes the sequence, the downstream survey found none (for neo4j only a two-file spot check), and the recorded searches found no report of it, although it has been in the code since 4.10.0 (2014) (see "Prior reports" for the searches and their gaps).

Minimal repro

One JUnit test in lucene/codecs, one method, public API only: one document with 26 terms, the format with its default block sizes, seekExact(0), the same-term TermState seek, then seekExact("b"). It fails on main and 10.6.0 with assertions on and off; without the TermState line it passes.

TestOrdsTermStateSeekAfterOrdSeek.java (lucene/codecs test, one method)57 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.codecs.blocktreeords;

import java.util.Locale;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.TermsEnum;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.BytesRef;

public class TestOrdsTermStateSeekAfterOrdSeek extends LuceneTestCase {

  public void testSeekExactAfterOrdSeekAndSameTermStateSeek() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig();
      iwc.setCodec(TestUtil.alwaysPostingsFormat(new BlockTreeOrdsPostingsFormat()));
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        Document doc = new Document();
        for (int i = 0; i < 25; i++) {
          doc.add(new StringField("f", String.format(Locale.ROOT, "a%02d", i), Field.Store.NO));
        }
        doc.add(new StringField("f", "b", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        TermsEnum te = getOnlyLeafReader(reader).terms("f").iterator();
        te.seekExact(0L);
        te.seekExact(BytesRef.deepCopyOf(te.term()), te.termState());
        // expected true ("b" exists; a00..a24 form a sub-block), actual false
        assertTrue(
            "existing term b: expected seekExact true, got false", te.seekExact(new BytesRef("b")));
      }
    }
  }
}
Command
cp $REPRO/TestOrdsTermStateSeekAfterOrdSeek.java $LUCENE/lucene/codecs/src/test/org/apache/lucene/codecs/blocktreeords/
cd $LUCENE && ./gradlew :lucene:codecs:test --tests org.apache.lucene.codecs.blocktreeords.TestOrdsTermStateSeekAfterOrdSeek -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
Expected
seekExact("b") returns true: b is in the dictionary.
Observed
seekExact("b") returns false (existing term b: expected seekExact true, got false), assertions on and off.

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

More files, environment, full output237 words

The test sits in the format's package by convention but uses only public classes and methods. It uses a plain ByteBuffersDirectory and IndexWriterConfig, runs single-threaded, and fails the same way with seeds DEADBEEF, C0DEFACE and 5EED1234 (five iterations), assertions on and off. It is also the suggested regression test, for example as a method of TestOrdsBlockTree.

Why 25 a terms: the default minimum block size is 25, and the writer gives a prefix its own block only when it has at least that many entries, so 25 terms that share the prefix a get their own block below the root. With fewer, every term of this test sits in the root block, the ord seek loads the root itself, and nothing is stale: with 24 a terms the test passes.

Without the te.seekExact(..., te.termState()) line the same test passes, with assertions on and off.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also 10.6.0 and every release run back to 4.10.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)

Observed output

TestOrdsTermStateSeekAfterOrdSeek > testSeekExactAfterOrdSeekAndSameTermStateSeek FAILED
    java.lang.AssertionError: existing term b: expected seekExact true, got false
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:2BBC219F910A50E9]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.codecs.blocktreeords.TestOrdsTermStateSeekAfterOrdSeek.testSeekExactAfterOrdSeekAndSameTermStateSeek(TestOrdsTermStateSeekAfterOrdSeek.java:52)
:lucene:codecs:test (FAILURE): 1 test, 1  failure

# the same with -Ptests.asserts=false; the standalone runner gives the same assertion on main and on 10.6.0
# without the TermState line: passes; with the recommended fix: the test passes

Why it looks unintended

  • seekExact(long) "Seeks to the specified term by ordinal"; the Expert TermState seek only requires a state from the same enum and field, which the test meets.
  • The class's own comment says an ord seek leaves the enum unpositioned "because we didn't set frames"; the shortcut flips the flag without setting them.
  • AssertingTermsEnum accepts the sequence; DirectPostingsFormat, also ord-capable, handles it.
The full argument344 words
  • Contract. seekExact(long): "Seeks to the specified term by ordinal (position) as previously returned by ord. The target ord may be before or after the current ord, and must be within bounds" (TermsEnum.java:91-95). The Expert seekExact(BytesRef, TermState): "Seeking by TermState should only be used iff the state was obtained from the same TermsEnum instance", for the same field (:97-116). "The TermsEnum is unpositioned when you first obtain it and you must first successfully call next or one of the seek methods" (:34-35): after seekExact(long) the enum is positioned in the API's sense.
  • The class's own invariant. seekExact(long) leaves positioned = false "because we didn't set frames for each byte leading up to current term" (OrdsSegmentTermsEnum.java:1135-1138); the shortcut sets it to true without setting them (:1079). next()'s comment promises that when "seek(TermState) was called" and the caller then calls next(), "this method catches up all internal state so next() works properly" (:933-938). The shortcut exists for exactly this re-seek (:1074-1078).
  • Lucene's own references accept it. AssertingTermsEnum marks the enum POSITIONED after seekExact(long) (AssertingLeafReader.java:384-389); through AssertingDirectoryReader no state-machine assertion fires and seekExact("b") is still false. DirectPostingsFormat returns the right answers for the same sequence. The default TermState seek in BaseTermsEnum is a plain seekExact(term) (BaseTermsEnum.java:67-72).
  • Why the tests miss it. TestOrdsBlockTree never calls termState(). RandomPostingsTester takes a fresh enum for every seek and uses either an ord seek or a TermState seek, never both (RandomPostingsTester.java:1612-1628). TestTermsEnum.testRandomSeeks mixes TermState seeks (:686) with next() (:731) on one enum but has no ord seeks.
  • History and precedent. The shortcut was copied from the core block-tree enum, where it is safe (no positioned flag, no ord seek); the ord seek and the unconditional positioned = true came in together with the format. LUCENE-3260 (#4333: an enum not positioned properly after a successful seek, so next() went wrong) and LUCENE-6395 (#7455: an ord-seek bug in MemoryIndex) were treated as bugs and fixed.
The devil's-advocate check127 words

Strongest case (all verifiers and the adjudication looked for it): both calls are Expert, experimental performance paths; a TermState re-seek to the term the enum is already on is redundant; and seekExact(long) deliberately leaves the enum "unpositioned", so calling seekExact(term, state) there is misuse.

Why it fails: no javadoc limits either call, the only stated TermState precondition holds, and the API and the test framework's state machine treat the enum as positioned after seekExact(long). The internal flag only means "the frames are valid", and the code's own comment says it must stay false here. @lucene.experimental is about API stability, not correctness. "Nobody does this" narrows who is hit; it does not make a false for an existing term intended. All verifiers rated the intended-behaviour case weak.

Suggested fix

Recommended Take the full TermState path when the enum is not positioned

In OrdsSegmentTermsEnum#seekExact(BytesRef, TermState), take the "already on target" shortcut only when the enum is positioned: add || !positioned to its condition (one line). The enum then loads the state like any TermState seek, and the next seek starts from the root. The test, TestOrdsBlockTree and the whole lucene/codecs suite pass.

--- a/lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java
+++ b/lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java
@@ -1061,7 +1061,7 @@ public final class OrdsSegmentTermsEnum extends BaseTermsEnum {
     // target.utf8ToString() + " " + target + " state=" + otherState);
     // }
     assert clearEOF();
-    if (target.compareTo(term.get()) != 0 || !termExists) {
+    if (target.compareTo(term.get()) != 0 || !termExists || !positioned) {
       assert otherState != null && otherState instanceof BlockTermState;
       BlockTermState blockState = (BlockTermState) otherState;
       currentFrame = staticFrame;
All options and trade-offs3 options · 549 words

Two small fixes (one changed condition; one moved statement) were built and run in a scratch worktree of 645d4fe14a20; both make the test pass. A third, larger design is listed for completeness.

Take the full TermState path when the enum is not positioned (validated by the checks) (recommended)

Add || !positioned to the shortcut's condition in seekExact(BytesRef, TermState) (OrdsSegmentTermsEnum.java:1064). After an ord seek the enum then moves to staticFrame with the copied state, exactly as after any TermState seek; the next seek starts from the root and next() catches up. The shortcut still applies when the frames are valid. The idea is the first finder's.

--- a/lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java
+++ b/lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java
@@ -1061,7 +1061,7 @@ public final class OrdsSegmentTermsEnum extends BaseTermsEnum {
     // target.utf8ToString() + " " + target + " state=" + otherState);
     // }
     assert clearEOF();
-    if (target.compareTo(term.get()) != 0 || !termExists) {
+    if (target.compareTo(term.get()) != 0 || !termExists || !positioned) {
       assert otherState != null && otherState instanceof BlockTermState;
       BlockTermState blockState = (BlockTermState) otherState;
       currentFrame = staticFrame;

Trade-offs

The full TermState branch (a state copy, a copy of the term bytes and a few field writes, OrdsSegmentTermsEnum.java:1065-1073) instead of nothing, and only for a same-term TermState seek right after an ord seek: that is the only state with positioned false and termExists true. On a fresh enum and after next() returned END, termExists is false, so those cases take the full branch already. No benchmark was run.

What the checks validated

Run in a scratch worktree of 645d4fe14a20, seed DEADBEEF: the repro and TestOrdsBlockTree pass with assertions on (38 tests, 2 skipped, 0 failures) and off (38 tests, 2 skipped, 0 failures); the whole lucene/codecs test suite passes with assertions on (694 tests, 78 skipped, 0 failures; that module only). An earlier pass the same day also ran them with nightly tests on and the format check. Only OpenJDK 25.0.3 on Linux aarch64; no benchmark, no full Lucene test run.

Leave positioned unchanged in the shortcut (validated by the checks)

Move positioned = true into the full TermState branch, so the shortcut keeps whatever the enum had (the independent reproducer's variant). After an ord seek the enum stays unpositioned on its valid ord-seek frame, and the next seek or next() re-seeks from the root.

Trade-offs

One moved statement plus a two-line comment. The full TermState branch is unchanged and still switches to staticFrame; only the same-term shortcut now keeps the current frame and leaves positioned as it was (false after an ord seek), so a following termState() or postings() reads the ord-seek frame, as it does today.

What the checks validated

Run the same way: the repro and TestOrdsBlockTree pass with assertions on (38 tests, 2 skipped, 0 failures) and off (38 tests, 2 skipped, 0 failures). Not run with this fix: the whole codecs suite, the format check, any benchmark.

Make seekExact(long) build every frame on the path (not built)

Load the frames for each prefix of the target term during the ord seek, so the enum is fully positioned afterwards.

Trade-offs

Removes the special unpositioned state, but adds work to every ord seek: loading each block on the path from the root, where the current code loads only the block that holds the target term (OrdsSegmentTermsEnum.java:1103-1129).

Downstream note

Relevant downstream only to code that seeks a BlockTreeOrds field by ord and then replays the enum's own TermState: there a term-existence check reports existing terms as missing, and on terms files over about 7 KB seekCeil can return made-up terms whose postings can carry doc IDs beyond maxDoc.

How it was found

A bounded-exhaustive check compared the BlockTreeOrds terms dictionary, call by call, with a sorted-list reference on every set of up to 3 terms and flagged mismatches. An exhaustive search over every TermsEnum call sequence up to length 4 isolated the 4-call trigger; code reading gave the cause, and the finder reduced it to one public-API JUnit.

The method in full211 words

Method: semi-formal, a hunt unit (codecs-module formats differential), 2026-10-26, on main ea57360912ed.

  • A bounded-exhaustive translation-validation harness (reused from an earlier unit, with a new BlockTreeOrds format spec) writes every small term set with the real IndexWriter, then checks the real reader against an exact sorted-array reference operation by operation. Over 64,200 term sets (up to 3 terms, 6 block configurations) it flagged mismatches and errors; with the fix of a sibling bug (LFV-157) applied, 40 mismatches and 26 errors remained, all this bug.
  • A model-based check enumerated every sequence of up to 4 TermsEnum calls (next, ord, seekExact(ord), termState, seekExact(term, saved state), seekCeil, seekExact) against a sorted-array model: 23 of 954,304 (3 terms), 40 of 2,141,490 (4 terms) and 46 of 4,193,820 (5 terms) sequences fail on stock main, 0 with the fix, every printed one (the 3-term log prints 15 of its 23) of the shape ord seek, termState, same-term TermState seek, then a seek or next. A cross-model reviewer noticed that the first version replayed TermStates captured on another enum; the model was fixed to capture them on the same enum and re-run.
  • With both fixes shadowed, runs found 0 mismatches over 33,424,158 term sets (2.65e10 operations).

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26, re-checked twice on 2026-10-30): GitHub issues, PRs and commits including the JIRA archive, the changed files of all open PRs, the Lucene mailing lists, three downstream projects and the web. Upstream main and branch_10_6 still have the code unchanged.

Searches4 searches

Related, not reports of this bug: #6881 (LUCENE-5819, which added the format; nothing on TermState seeks after ord seeks); #7587 (LUCENE-6529, wrong results with ord-capable codecs from another cause; rmuir noted there that the postings test base does not test seek-by-ord); #7455 (LUCENE-6395, an ord-seek bug in MemoryIndex, fixed); #4142 (LUCENE-3069, mikemccand's design note that later calls must work after a TermState seek); #4333 (LUCENE-3260, next() after a seek in MultiTermsEnum, 2011). The only open PR that touches the file, #14716 ("Merge PostingsEnum and ImpactsEnum"), removes impacts() and leaves both seek methods alone. LFV-157 is the hunt's own finding in the same method family, not prior art. Search limits: GitHub search does not index attached logs, closed unmerged PRs could not be listed, JIRA attachments were not read.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs and commits incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr; the web)2026-10-26two automated runs (51 and 78 queries, 3,297 hits) and 45 manual GitHub searches over class, method and symptom terms (OrdsSegmentTermsEnum, BlockTreeOrds seekExact TermState, positioned, scanToSubBlock assertion, EOFException); 51 items read in fullno report; only the introducing issue and unrelated ord or TermState work
Open apache/lucene PRs and git history2026-10-26changed files of all 408 open PRs; the full history of OrdsSegmentTermsEnum.javaonly #14716 touches the file (it removes impacts()); no change to either seek method's logic since 2014
Mailing lists and JIRA2026-10-26dev@, java-user@, issues@, commits@, builds@ and the Solr lists; LUCENE-5819, LUCENE-6529, LUCENE-5691, SOLR-7631, SOLR-10986 readno report; build mails name the format only in random-codec lines
Refresh2026-10-30 (file blobs again)upstream heads and the file's blob on main and branch_10_6; issue and PR search for OrdsSegmentTermsEnum and BlockTreeOrds; #14716's diffno new item; the file is identical to 645d4fe14a20 on both; #14716 still open and does not touch the seek methods

History

Present since the format was added: LUCENE-5819 (80fe3c277f3, 2014-07-20, now #6881), first released in 4.10.0. Executed and failing on 4.10.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.1.0, 9.12.3, 10.5.2, 10.6.0 and main; never fixed.

Versions tested9 rows

LUCENE-5819 "add terms dict and postings format that implement term ordinals" (mikemccand; 80fe3c277f30 on trunk, 2014-07-20, and its branch_4x merge 96c1de3589d, 2014-07-21; pre-GitHub, no PR) derived OrdsSegmentTermsEnum from the core block-tree enum. It kept the core TermState shortcut, which is safe in core (no positioned flag, no ord seek), added seekExact(long) with positioned = false, and set positioned = true unconditionally after the shortcut. Later commits on these lines only renamed term to term.get() (LUCENE-5864) and reformatted (LUCENE-9570). The format shipped in 4.10.0 as Ords41PostingsFormat and was renamed BlockTreeOrdsPostingsFormat in 5.0. The logic is unchanged at all 103 release tags that contain the commit in the history clone. Releases up to 6.6.6 cannot start their JUnit test frameworks on JDK 25, so they ran main() ports of the same test.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 (11.0.0-SNAPSHOT) affectedthe test fails, assertions on and off (gradle and standalone)
upstream main 2f424fd428da, branch_10_6 head readOrdsSegmentTermsEnum.java identical to 645d4fe14a20 (2026-10-30)
10.6.0 (4ae946e5a9b) affectedthe test fails, assertions on and off (release jars)
10.5.2, 9.12.3 affectedfail with assertions on and off (the finder's 4-method test and the independent tests)
9.1.0, 8.11.4, 7.7.3 affectedfail (the finder's 4-method test; 8.x package names for 7.x and 8.x)
6.6.6, 5.5.5, 4.10.4, 4.10.0 main() portsfail: seekExact false, seekCeil EOFException, next() throws; controls pass
4.10.0 with the one-line fix not affectedpasses
4.9.0, 4.9.1 format absentno BlockTreeOrds classes or postings format
main 645d4fe14a20 with fix A not affectedthe test, TestOrdsBlockTree and the whole lucene/codecs suite pass

Independent checks

The finder, independent verifiers, a cross-model reviewer (OpenAI GPT-5.6, reading only), an adjudication and a credibility audit rate it real; an earlier cross-model review said not real only because it could run no Java. This doc's runs reproduced it again and validated both fixes.

Checks and verdicts346 words

Every verdict that rests on execution re-ran a repro on stock jars. Neither cross-model review executed code; separate runs re-ran the finder's test (3 of 4 methods fail). The hunt's runs on main (the finder, the verifiers, the adjudication, the security probe, the registry re-runs and the audit) used ea57360912ed (2026-10-24); between it and 645d4fe14a20 nothing changed in lucene/codecs, the FST package, TermsEnum, BaseTermsEnum or AssertingLeafReader.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidateExhaustive call-sequence model: 23/954,304, 40/2,141,490, 46/4,193,820 failing sequences, 0 with the fix; the 4-method JUnit fails 3 of 4 on main, 10.5.2, 9.12.3, 9.1.0, 8.11.4 and 7.7.3.
cross-model review, refute lens (OpenAI GPT-5.6, reading only) not real, low (procedural)Said not real solely because no independent Java run happened; agreed on the root cause; found a flaw in the first model (TermStates replayed across enums), which was fixed and re-run.
Independent reproducer realOwn exhaustive test: 120/1,849 seekExact, 400/2,279 seekCeil, 40/43 next() wrong on main, 10.5.2 and 9.12.3 (assertions on and off); controls pass; its own different fix passes.
Cross-model review, works-as-designed lens (OpenAI GPT-5.6, reading only) real, mediumIntended-behaviour argument weak; medium only because it could not run code.
Intended-behaviour devil's advocate real, highRejected every intended-behaviour argument; AssertingTermsEnum accepts the sequence, DirectPostingsFormat handles it; reach probe over enum histories.
adjudication confirmed, highRe-ran the independent reproduction's test (assertions on and off), the finder's with another seed, and the independent reproduction's fix.
credibility audit keepRe-ran on main with fresh seeds (fail; with the fix, pass); "Real and cleanly explained, with an obvious one-line fix", to be presented as a low-priority correctness fix.
this doc's runs reproduced; fixes validatedGradle: stock fails (assertions on and off); fix A passes with TestOrdsBlockTree (assertions on and off) and the whole codecs suite; fix B passes with TestOrdsBlockTree (assertions on and off). The standalone runner fails on main and 10.6.0. An earlier pass the same day added nightly and format checks and an internal-state probe.

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,687 words

Finding record

LFV-487, snapshot of 2026-11-06

Registry title
BlockTreeOrds TermsEnum: seekExact(term, termState) right after seekExact(ord) marks the enum positioned over an incomplete frame stack, so a later seekExact returns false for an existing term and seekCeil/next() throw or return made-up terms
Area
codecs / BlockTreeOrds terms dictionary (lucene-codecs OrdsSegmentTermsEnum)
Priority
Low, rank 599: Sibling of LFV-157 (a TermState seek leaves the block-tree enum in the wrong position), but in the opt-in BlockTreeOrds codec and only after a redundant ord-seek followed by a same-term TermState call.
Impact class
silent wrong results
Impact note
Applications that write a field with the opt-in lucene-codecs BlockTreeOrdsPostingsFormat (not the default codec) and, on one TermsEnum, call seekExact(ord), then the Expert seekExact(sameTerm, termState()) and then seek to a term in another block or call next() past the end of the current block get wrong answers whenever the ord-sought term lies below the root block (with default block sizes, about 25 or more terms sharing a prefix): seekExact returns false for an existing term with -ea and -da, next() throws ArrayIndexOutOfBoundsException (-da) or AssertionError (-ea), and seekCeil throws EOFException on a tiny terms file but, once the segment's terms file is larger than about 7 KB, silently returns NOT_FOUND with a made-up term decoded from the terms-file header, whose postings can carry doc IDs beyond maxDoc; no in-tree query, merge or CheckIndex path makes this sequence and no downstream caller is known; no scale needed.
Summary
You index a field with BlockTreeOrdsPostingsFormat (here 26 terms a00..a24 and b in one segment, default block sizes), position a TermsEnum with seekExact(0), take its termState() and call seekExact(term(), state) on the same enum, which the TermsEnum javadoc allows and which leaves it on a00. You expect a later seekExact("b") to return true, seekCeil("b") to return FOUND and next() after a24 to return "b", as they do without the extra TermState call and with every other terms dictionary. Instead seekExact("b") returns false, seekCeil("b") throws EOFException (or, in a segment whose terms file is larger than about 7 KB, returns NOT_FOUND with a made-up term) and next() after a24 throws, because seekExact(ord) loads only the target block and deliberately leaves the enum unpositioned, while the TermState seek's 'already on this term' shortcut marks it positioned anyway, so the next call reuses parent frames that were never loaded.
Introduced
80fe3c277f30c275573e4f4a72096b695c0c2dec (LUCENE-5819 'add terms dict and postings format that implement term ordinals', svn r1612080 on trunk, by mikemccand; the JIRA issue is now apache/lucene#6881; pre-GitHub, no PR) and its branch_4x svn merge 96c1de3589d (r1612213, identical seek logic), which shipped in 4.10.0. The commit derived OrdsSegmentTermsEnum from the core block-tree SegmentTermsEnum, kept the core TermState seek shortcut ('skip seek: already on target state'; safe in core, which has no positioned flag and whose seekExact(long) throws UnsupportedOperationException), added seekExact(long), which loads only the target block's frame and sets positioned = false ('Leave enum fully unpositioned, because we didn't set frames for each byte leading up to current term'), and set positioned = true unconditionally after the copied shortcut [read: git blame, git log -L on both seek methods and git log -G positioned in the full history clone; the only later commits on these lines are 94d1b7f5a64 (LUCENE-5864, term -> term.get()) and 7f8b7ffbcad (LUCENE-9570 reformatting), neither semantic]. Not a regression [exec: the one-line fix applied to the 4.10.0 source passes, an unmodified shadow copy of the same source still fails] (2014-07-20 (trunk 80fe3c277f3; branch_4x 96c1de3589d on 2014-07-21; first released in 4.10.0, tagged 2014-09-02))
First affected version
4.10.0 [exec, -ea and -da: seekExact(b) false, seekCeil(b) EOFException, next() AssertionError 'fp=0 subFP=73' (-ea) or ArrayIndexOutOfBoundsException (-da); control passes] (the first release with the format, then Ords41PostingsFormat with SPI name OrdsLucene41, renamed BlockTreeOrdsPostingsFormat in 5.0); 4.9.0 and 4.9.1 have no blocktreeords package [exec: absence probe], so no release was ever unaffected
Affected versions
every release from 4.10.0 through 10.6.0 (10.6.0 [exec] on its release jars, in the bug doc's runs) and main ea57360912ed; never fixed. [exec] main() ports of the same test on 4.10.0 (-ea, -da), 4.10.4, 5.5.5 (-ea, -da) and 6.6.6 (-ea, -da); the JUnit on 7.7.3 and 8.11.4 (repro/src-8x), 9.1.0 (finder), 9.12.3 (-ea, -da), 10.5.2 (-ea, -da) and main (-ea, -da): the same three symptoms everywhere (only the file offset in the next() assertion message differs), control passes; the independent reproduction and the intended-behaviour check re-ran on 10.5.2 and 9.12.3; the security probe on 10.5.2 (-da). [read] the unchanged logic at all 103 release tags that contain the commit in the history clone (4.10.0-8.8.1 and 9.0.0-10.5.2; 8.8.2-8.11.x are not tagged there, 8.11.4 was executed); upstream main, branch_10x, branch_10_6 and branch_9_12 still have it and no open PR changes either seek method (2026-10-26). The default codec's block-tree enum is not affected: its seekExact(long) throws UnsupportedOperationException. All runs used JDK 25 on aarch64
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check; also reached by code reading
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new, high confidence for the searched sources): two scripted prior-report searches (the finder's 51 queries with 991 hits; the follow-up's 78 queries over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, 2,306 hits, 0 high), 45 manual GitHub GraphQL searches, 51 items read in full, the changed files of all 408 open apache/lucene PRs (only #14716 touches OrdsSegmentTermsEnum.java, and only to remove impacts()), the full git history, and manual list and JIRA searches: no issue, PR, commit, JIRA entry, list thread or web page reports it. Not fixed upstream: main, branch_10x, branch_10_6 and branch_9_12 still have the shortcut with an unconditional positioned = true. Related, not reports: #6881 (LUCENE-5819, the introducing issue; nothing on TermState seeks after ord seeks), #7587 (LUCENE-6529 / SOLR-7631: wrong results with ord-capable codecs from a different cause; rmuir noted there that BasePostingsFormatTestCase does not test seek(ord)), #7455 (LUCENE-6395, an ord-seek bug in MemoryIndex: precedent that such bugs get fixed), #4142 (LUCENE-3069: mikemccand's note that later calls must work after a TermState seek), #4333 (LUCENE-3260, next() after a seek in MultiTermsEnum, 2011). Gaps: GitHub search does not index attached logs, closed unmerged PRs could not be enumerated, JIRA attachments were not read
Lucene commit tested
main 645d4fe14a20 (11.0.0-SNAPSHOT, commit of 2026-10-28): the repro through gradle in a scratch worktree, assertions on and off, stock and with each of the two fixes; with fix A also TestOrdsBlockTree (assertions on and off) and the whole lucene/codecs test suite; the standalone runner with assertions on and off. 10.6.0 release jars (4ae946e5a9b). An earlier doc pass the same day added nightly and format checks with fix A and an internal-state probe. Older releases come from the hunt and its follow-up (4.10.0 to 10.5.2, the 4.x-6.x ones as main() ports). Upstream main (2f424fd428da) and branch_10_6 were read on 2026-10-30: OrdsSegmentTermsEnum.java is identical to 645d4fe14a20. Only Linux aarch64 and OpenJDK 25.0.3 were run. The hunt's runs on main (the finder, the verifiers, the adjudication, the security probe, the registry re-runs and the audit) used ea57360912ed (2026-10-24); between it and 645d4fe14a20 nothing changed in lucene/codecs, the FST package, TermsEnum, BaseTermsEnum or AssertingLeafReader.

Lucene and upstream sources

Finding record
  • lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java:1058-1080 (at ea57360912ed) (seekExact(BytesRef, TermState): shortcut condition at :1064 ignores 'positioned'; unconditional 'positioned = true' at :1079), :1094-1139 (seekExact(long): pushes only the target block's frame; 'Leave enum fully unpositioned' comment at :1135-1136, 'positioned = false' at :1138), :256 (seekExact(BytesRef)) and :530 (seekCeil) reuse frames when positioned && currentFrame != staticFrame, :701 (seekCeil END branch calls next()), :932 (next() re-seeks only if currentFrame == staticFrame || positioned == false), :948 and :969 (loadNextFloorBlock and scanToSubBlock calls seen in the stack traces)
  • lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnumFrame.java:44-75 (at ea57360912ed) and 105-110 (frame fields left at Java defaults, fp = 0), 137-146 (loadNextFloorBlock), 157-234 (loadBlock), 557 (scanToSubBlock assertion 'fp=... subFP=...'), as cited by the security record
  • lucene/core/src/java/org/apache/lucene/index/TermsEnum.java:91-116 (at ea57360912ed) (seekExact(long) 'Seeks to the specified term by ordinal'; Expert seekExact(BytesRef, TermState) with the same-instance and same-field preconditions only)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java:93-104, :231, :256, :326-334, :420-427, :530, :701, :932-969, :1058-1080, :1094-1139
  • a Lucene main checkout (645d4fe14a20) lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnumFrame.java:105-110, :137-146, :157-234, :164-167, :557, :699-704
  • a Lucene main checkout (645d4fe14a20) lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsBlockTreeTermsWriter.java:101, :107 (default block sizes 25, 48)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/SegmentTermsEnum.java:1058-1060 (seekExact(long) throws UnsupportedOperationException)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:2407, :2417 (seekExact(ord), then seekCeil)
  • a Lucene main checkout (645d4fe14a20) non-test sources searched for two-argument seekExact calls: lucene/core/src/java/org/apache/lucene/search/TermQuery.java:141, :213, SynonymQuery.java:312, PhraseQuery.java:526, MultiPhraseQuery.java:277, CombinedFieldQuery.java:354, MultiTermQueryConstantScoreWrapper.java:62, MultiTermQueryConstantScoreBlendedWrapper.java:70, FuzzyTermsEnum.java:306 (delegates); lucene/core/src/java/org/apache/lucene/index/FilterLeafReader.java:239 (delegates); lucene/queries/src/java/org/apache/lucene/queries/spans/SpanTermQuery.java:158; lucene/sandbox/src/java/org/apache/lucene/sandbox/search/PhraseWildcardQuery.java:288, TermAutomatonQuery.java:421, CombinedFieldQuery.java:389; none of these files calls seekExact(long) except the delegating FuzzyTermsEnum (:340-341) and FilterLeafReader (:198-199)
  • a Lucene main checkout (645d4fe14a20) lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java:1032-1041 (postings() delegates to the postings reader); lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/BlockTreeOrdsPostingsFormat.java:83 (Lucene104PostingsReader); lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:503-506, :561-562 (docIn clone, seek to termState.docStartFP)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/TermsEnum.java:34-35, :91-95, :97-116
  • a Lucene main checkout (645d4fe14a20) lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java:933-938, :1074-1079, :1135-1138
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:384-389
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/BaseTermsEnum.java:67-72
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomPostingsTester.java:1612-1628
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestTermsEnum.java:686, :731; lucene/codecs/src/test/org/apache/lucene/codecs/blocktreeords/TestOrdsBlockTree.java (no termState() call)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java:51, :957-958, :1064-1079, :1133, :1138 (termExists/positioned states; the full TermState branch)
History
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:11875-11878 (LUCENE-5819 entry)
Independent checks
  • a Lucene main checkout (645d4fe14a20) git diff --stat ea57360912ed 645d4fe14a20 (no change under lucene/codecs, util/fst, TermsEnum, BaseTermsEnum, AssertingLeafReader; in core codecs/lucene104 only Lucene104ScalarQuantizedVectorsWriter.java)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the minimal test and an executed probe of the enum's frames.

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.