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.
- A
TermsEnumwalks the sorted terms of one field. Besidesnext()and seeking by term, it can save its place as aTermState(termState()) and jump back withseekExact(term, state); with an ord-capable format such as BlockTreeOrds it can also jump to the n-th term withseekExact(ord). - 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.
- 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.
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.- Index one document whose field
fhas 26 terms,a00toa24andb, withBlockTreeOrdsPostingsFormatand its default block sizes. The 25aterms get their own block; the root block holds that block andb. - On one
TermsEnum, callseekExact(0): it lands ona00and loads only theablock. Then callseekExact(term(), termState())with the enum's own state, a redundant re-seek theTermsEnumjavadoc allows. - Call
seekExact("b"). You expecttrue:bexists, and without the TermState call the same enum returnstrue. - 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. - Same cause:
seekCeil("b")throwsEOFException(or, once the terms file is larger than about 7 KB, returns a made-up term read from the file's header), andnext()aftera24throws.
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 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.
- A fresh enum loads nothing. The constructor sets
currentFrame = staticFrame; the root push is commented out (OrdsSegmentTermsEnum.java:93-104). seekExact(0)loads only blocka(:1094-1139). The reverse lookup in the terms index finds the block prefixa, socurrentFrame = getFrame(0)(:1111) andpushFrame(arc, io.output, 1)(:1117) pushes and loadsstack[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" andpositioned = false(:1135-1138). Probe:positioned=false;stack[0] hasTerms=false fp=0 nextEnt=0 entCount=0;stack[1] fp=130 entCount=25.seekExact(term(), termState())takes the shortcut (:1058-1080). Its conditiontarget.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, andpositioned = true(:1079) runs anyway. Probe:positioned=true, frames unchanged.seekExact("b")trusts the stale frames.positioned && currentFrame != staticFrame(:256) holds, so it reuses them:a00<b, so it continues fromstack[0](:326-334). The terms index has no arc forb, so it checksif (!currentFrame.hasTerms)(:420); the never-loaded root frame still has the defaultfalse, and the method returns false (:421-427). Probe:seekExact(b)=false,currentFrame.ord=0.seekCeil("b")reuses the frames the same way (:530).loadBlocktreats the root frame as loaded, becausenextEntis 0, not -1 (OrdsSegmentTermsEnumFrame.java:164-167); the scan finds no entry (scanToTermNonLeafreturns END,OrdsSegmentTermsEnumFrame.java:699-704) and callsnext()(OrdsSegmentTermsEnum.java:701), which callsloadNextFloorBlock(: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 (theEOFException, and the made-up term below).next()aftera24re-seeks only whencurrentFrame == staticFrame || positioned == false(OrdsSegmentTermsEnum.java:932), so the catch-up its comment promises after a TermState seek is skipped. At the end of blockait pops tostack[0](:961-962) and callsscanToSubBlock(130)(:964-969), which assertsfp=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.
- 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 returnsNOT_FOUNDon a 23-byte "term" made of header bytes (it contains the textOrdsBlockTreeTerms); its postings throw, or in another layout return doc IDs 13910 and 13933 in a segment withmaxDoc3000; furthernext()calls return 30 more byte strings cut from the terms file, then END. - An independent exhaustive test (
k00..k39in a sub-block plusm,n0,n1in the root block; every start ord against every target):seekExactwrong 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);seekCeilwrong 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,seekExactwas 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 singlenext()on a 2-level tree, but many after a fresh start, a priorseekExact(term), a priorseekExact(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, callseekExact(ord), thenseekExact(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 ornext()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 prefixaget 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
UnsupportedOperationExceptionfromseekExact(long). Lucene itself: every in-tree TermState seek (TermQuery,SynonymQuery,PhraseQuery,MultiPhraseQuery,CombinedFieldQuery,SpanTermQuery, the multi-term query wrappers, and the sandboxPhraseWildcardQueryandTermAutomatonQuery) is in code that never callsseekExact(long),FuzzyTermsEnumandFilterLeafReaderonly delegate both calls, andCheckIndexdoesseekExact(ord)thenseekCeilwith 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.
seekExactis silently wrong with assertions on and off;next()fails loudly;seekCeilfails loudly only on tiny terms files and otherwise returns a made-up term whose postings can carry doc IDs beyondmaxDoc. 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).
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.
/*
* 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")));
}
}
}
}
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:bis in the dictionary.- Observed
seekExact("b")returns false (existing term b: expected seekExact true, got false), assertions on and off.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also 10.6.0 and every release run back to 4.10.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
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 passesseekExact(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.
AssertingTermsEnumaccepts 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 ExpertseekExact(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): afterseekExact(long)the enum is positioned in the API's sense. - The class's own invariant.
seekExact(long)leavespositioned = 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 callsnext(), "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.
AssertingTermsEnummarks the enumPOSITIONEDafterseekExact(long)(AssertingLeafReader.java:384-389); throughAssertingDirectoryReaderno state-machine assertion fires andseekExact("b")is still false.DirectPostingsFormatreturns the right answers for the same sequence. The default TermState seek inBaseTermsEnumis a plainseekExact(term)(BaseTermsEnum.java:67-72). - Why the tests miss it.
TestOrdsBlockTreenever callstermState().RandomPostingsTestertakes a fresh enum for every seek and uses either an ord seek or a TermState seek, never both (RandomPostingsTester.java:1612-1628).TestTermsEnum.testRandomSeeksmixes TermState seeks (:686) withnext()(: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
positionedflag, no ord seek); the ord seek and the unconditionalpositioned = truecame in together with the format. LUCENE-3260 (#4333: an enum not positioned properly after a successful seek, sonext()went wrong) and LUCENE-6395 (#7455: an ord-seek bug inMemoryIndex) 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.
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.
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.
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).
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.
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
TermsEnumcalls (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 ornext. 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).
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs and commits incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr; the web) | 2026-10-26 | two 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 full | no report; only the introducing issue and unrelated ord or TermState work |
| Open apache/lucene PRs and git history | 2026-10-26 | changed files of all 408 open PRs; the full history of OrdsSegmentTermsEnum.java | only #14716 touches the file (it removes impacts()); no change to either seek method's logic since 2014 |
| Mailing lists and JIRA | 2026-10-26 | dev@, java-user@, issues@, commits@, builds@ and the Solr lists; LUCENE-5819, LUCENE-6529, LUCENE-5691, SOLR-7631, SOLR-10986 read | no report; build mails name the format only in random-codec lines |
| Refresh | 2026-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 diff | no new item; the file is identical to 645d4fe14a20 on both; #14716 still open and does not touch the seek methods |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 (11.0.0-SNAPSHOT) | affected | the test fails, assertions on and off (gradle and standalone) |
upstream main 2f424fd428da, branch_10_6 head | read | OrdsSegmentTermsEnum.java identical to 645d4fe14a20 (2026-10-30) |
10.6.0 (4ae946e5a9b) | affected | the test fails, assertions on and off (release jars) |
| 10.5.2, 9.12.3 | affected | fail with assertions on and off (the finder's 4-method test and the independent tests) |
| 9.1.0, 8.11.4, 7.7.3 | affected | fail (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() ports | fail: seekExact false, seekCeil EOFException, next() throws; controls pass |
| 4.10.0 with the one-line fix | not affected | passes |
| 4.9.0, 4.9.1 | format absent | no BlockTreeOrds classes or postings format |
main 645d4fe14a20 with fix A | not affected | the test, TestOrdsBlockTree and the whole lucene/codecs suite pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (a hunt unit) | candidate | Exhaustive 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 | real | Own 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, medium | Intended-behaviour argument weak; medium only because it could not run code. |
| Intended-behaviour devil's advocate | real, high | Rejected every intended-behaviour argument; AssertingTermsEnum accepts the sequence, DirectPostingsFormat handles it; reach probe over enum histories. |
| adjudication | confirmed, high | Re-ran the independent reproduction's test (assertions on and off), the finder's with another seed, and the independent reproduction's fix. |
| credibility audit | keep | Re-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 validated | Gradle: 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. |
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 571 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,687 words
- 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 alsoTestOrdsBlockTree(assertions on and off) and the wholelucene/codecstest 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 asmain()ports). Upstream main (2f424fd428da) and branch_10_6 were read on 2026-10-30:OrdsSegmentTermsEnum.javais identical to645d4fe14a20. 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) usedea57360912ed(2026-10-24); between it and645d4fe14a20nothing changed inlucene/codecs, the FST package,TermsEnum,BaseTermsEnumorAssertingLeafReader.
- 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 recordlucene/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-1139a 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-704a 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-116a Lucene main checkout (645d4fe14a20) lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java:933-938, :1074-1079, :1135-1138a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:384-389a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/BaseTermsEnum.java:67-72a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomPostingsTester.java:1612-1628a 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)
- TestOrdsTermStateSeekAfterOrdSeek.java (lucene/codecs test, one method):
repro/LFV-487/src/org/apache/lucene/codecs/blocktreeords/TestOrdsTermStateSeekAfterOrdSeek.java(57 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the minimal test and an executed probe of the enum's frames.
assets/LFV-487/bug.png(571 KB)
