All findings · Lucene finding · LFV-157 · blocktree-next-after-termstate-seek-restarts

Block-tree TermsEnum.next() after seekExact(term, TermState) on a new enum restarts at the field's first term

Position a brand-new default-codec TermsEnum with seekExact(term, TermState) and next() restarts at the field's first term: terms a, b, c, seek to b, next() gives a, not c. Every release since 4.0 has the same code; no in-tree Lucene query calls next() there, and no wrong search result is known.

Summary

Priority
Low, rank 598Block-tree TermsEnum.next() after seekExact(term, TermState) on a fresh enum restarts at the field's first term, silent but only for custom code driving that Expert call sequence, under a disputed 'same TermsEnum instance' contract.
Impact
silent wrong results
Since
4.0.0-alpha (LUCENE-3562, 2011)introduced 2011-11-17 · over 14 years
Affected
4.0.0-alpha to 10.6.0, and main
Status
documented
Found by
AI tools checking the block tree against a sorted-array reference

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

A TermState seek as the first call on a new block-tree enum, then next(): the enum starts over at the field's first term.

Background: TermsEnum and TermState

  1. A TermsEnum walks the terms of one field in one segment in sorted order: next() moves to the following term, and the seek methods jump to a given term.
  2. A TermState is a saved position of one term. The block-tree dictionary's seekExact(term, state) jumps straight to it without searching the terms dictionary (the javadoc allows this for low-level implementations). TermQuery builds such states with TermStates.build and applies each one to a new enum of its segment to read the term's postings.
  3. A block-tree enum makes its private copy (clone) of the terms-file input lazily, on the first block of terms it reads. A TermState seek reads no block, so a query that only needs postings never pays for that copy.
Summary card. Terms a, b, c in one segment; te.seekExact(b, state) with the state from TermStates.build, then te.next() returns a (then b, c, null) instead of c; default codec, silent, with assertions on and off. Left: the three term cards, the enum on b, an arc to a marked actual and an arc to c marked expected; before next(), term() is b and docFreq() is 1. Right: seekExact(BytesRef, TermState) sets currentFrame = staticFrame and loads no block, so the lazily cloned input stays null as in a brand-new enum; next() tests in == null (Fresh TermsEnum) first and pushes the root frame, so the catch-up test if (currentFrame == staticFrame) is false and its re-seek never runs. Bullets: every release from 4.0.0-alpha to 10.6.0 and main, also BlockTreeOrds and the Lucene90 block tree; rare Expert call sequence. Suggested fix: if (in == null && seekedByTermState == false), the flag set by the TermState seek and cleared by seekExact(BytesRef) and seekCeil; with it the minimal repro and 227 upstream tests give 0 failures, 14 nightly-only tests skipped.
Terms a, b, c: a TermState seek puts a new enum on b, and next() returns a instead of c, because the fresh-enum test runs before the catch-up. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index three documents whose field f holds a, b and c (one segment, default codec), and take the TermState of f:b with TermStates.build(...), as TermQuery does.
  2. Open a new enum on f and call seekExact("b", state). term() reports b and docFreq() 1, as expected.
  3. Call next(). You expect c: terms come in increasing order, and every other terms dictionary returns the next term after the same calls.
  4. You get a, the field's first term, with no error, assertions on or off. The next calls return b, c, null: the whole field again, including the terms before b.
  5. Why: the TermState seek reads no block of terms, so the enum's private copy of the terms-file input is still null. next() takes a null input to mean "new enum, start at the first term" and skips the catch-up code written for exactly this case.

Who is affected

Only code that drives the Expert TermsEnum API itself: it seeks a new enum with a TermState from TermStates, TermQuery.getTermStates() or a saved cursor, then calls next(), and gets wrong terms every time; a paging loop that resumes each page this way makes no progress. Default codec, BlockTreeOrds, 9.x-10.2 indexes. No in-tree query does this.

Mechanism, terms and numbers617 words

TermsEnum.seekExact(BytesRef, TermState) puts an enum on a term from saved state; the block-tree terms dictionaries do this without re-seeking the dictionary (the javadoc allows it for low-level implementations; the generic BaseTermsEnum just seeks the term). TermStates.build and TermQuery.getTermStates() hand out such states, and TermQuery applies them to a new enum of each segment. With the block-tree terms dictionary (the default codec, BlockTreeOrds and the Lucene90 block tree used for 9.x-10.2 indexes), when that seek is the first call on a new enum, term() and docFreq() are right but next() returns the field's first term and the enum then lists the whole field again. The seek leaves the lazily cloned input null, and next() reads a null input as "fresh enum" before its own catch-up branch for TermState seeks can run. Three one-term documents a, b, c: after the seek to b, next() returns a instead of c, silently, with assertions on and off (main and 10.6.0). Every release from 4.0.0-alpha to 10.6.0 has the same code order, and every release that was run fails the same way. No in-tree Lucene query calls next() after a TermState seek and no wrong search result is known; code that drives the Expert TermsEnum API directly is affected.

The bug

  1. The SegmentTermsEnum constructor sets currentFrame = staticFrame and does not clone the terms input (SegmentTermsEnum.java:60-69, the root-block load at :68-69 commented out); initIndexInput() clones it lazily (:81-85), when a frame first prefetches or loads a block (SegmentTermsEnumFrame.java:147, :167).
  2. seekExact(BytesRef, TermState) (:1027-1046) sets currentFrame = staticFrame (:1035), copies the state and the term, and loads no block, so in stays null.
  3. next() (:892) first tests if (in == null) { // Fresh TermsEnum; seek to first term (:893) and makes the root frame the current frame in place of staticFrame (:896).
  4. Only then comes if (currentFrame == staticFrame) (:911-922), whose comment says that after seek(TermState), "if they then call next(), this method catches up all internal state so next() works properly". It re-seeks the term (:920), but step 3 already replaced currentFrame, so it never runs and next() returns the first term of the root block.
  5. The same order is in BlockTreeOrds (OrdsSegmentTermsEnum.java:906 before :932) and in the Lucene90 block tree in backward-codecs (SegmentTermsEnum.java:983 before :1008); the sandbox IDVersionSegmentTermsEnum has it too (next() at :1016, its fresh-enum branch at :1018-1019).

The numbers

  • Terms a, b, c, seek to b: term() is b and docFreq() 1, then the next() calls return a, b, c, null instead of c, null. A seek to a gives a, b, c, null (expected b, c, null); a seek to c gives a, b, c, null (expected null).
  • Terms kiwi, lemon, mango, olive, peach: for every one of the five targets the first next() returns kiwi; for peach, the last term, it should return null; after the seek to mango the next() calls return kiwi, lemon, mango, olive, peach instead of olive, peach.
  • 3,000 terms over several blocks: targets #0, #1, #1500, #2998 and #2999 all return the first term.
  • An exhaustive check of every set of 1-3 short terms (the writer set to 2 to 3 entries per block) against a sorted-array reference: 10,277 of 10,700 sets mismatch, every mismatch a next() after the TermState seek.
  • Not affected: a state taken from the same enum, an enum positioned once by an ordinary seek or next() first, and term(), docFreq(), totalTermFreq(), postings() and impacts() right after the seek (a probe asserts all five on the default block tree, BlockTreeOrds and the Lucene90 block tree).
Exposure and likelihood385 words
  • Who is hit. Application or library code that drives TermsEnum directly: it positions a brand-new block-tree enum with seekExact(term, state), the state taken from TermStates, TermQuery.getTermStates() or a saved (term, state) pair, and then calls next(), for example to list the terms after a known term or to resume term paging. Such code gets wrong terms every time.
  • What it does. A paging loop that opens a new enum for each page and resumes it from the previous page's last (term, TermState) makes no progress, because each resumed page starts again at the field's first term: capped at 200 pages of 100 it delivered 20,000 terms for a field with 2,706 distinct terms and had not finished; with the fix it finished in 28 pages. A listing that starts at a TermState and stops at an upper bound also returns every term below its start. The wrong listing stays inside the same field and segment and ends after one extra pass.
  • Who is not. Lucene's queries. Every in-tree caller of the TermState seek reads only postings, impacts or statistics afterwards: outside sandbox TermQuery, SynonymQuery, PhraseQuery, MultiPhraseQuery, CombinedFieldQuery, the multi-term constant-score wrappers and SpanTermQuery; in sandbox PhraseWildcardQuery, TermAutomatonQuery and the sandbox CombinedFieldQuery. FuzzyTermsEnum and FilterLeafReader only pass the call on to another enum. 57 queries over the paths outside sandbox, on an index with deletions and with the bug active in 4 of 4 leaves, matched a SimpleText oracle exactly in all hits and their scores, the total hit counts, count(), and the explanation's match flag and value for the top three hits of each query. The downstream code read (Elasticsearch, OpenSearch and its security plugin, Solr; default branches, not exhaustive) has no caller that calls next() after a TermState seek. So no wrong Lucene search result is known; this covers today's in-tree callers, not future or downstream code.
  • Default vs opt-in. Default codec and every block-tree terms dictionary; the trigger is an Expert call sequence on an @lucene.experimental class (TermsEnum.java:37).
  • Scale. One segment and three terms are enough; deterministic.
  • How likely. Rare: the recorded search found no public report since 4.0 (2012), although a 2011 dev@ thread shows a user calling seek(BytesRef, TermState) followed by next(), before block tree existed.

Minimal repro

One JUnit class, one method; the code under test uses only public Lucene APIs (the harness is Lucene's test framework): three one-term documents, default codec, the state from TermStates.build as TermQuery gets it. It fails with assertions on and off and is also the suggested regression test.

TestNextAfterSeekExactTermState.java (JUnit for Lucene's build)50 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.index;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.IndexSearcher;
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;

public class TestNextAfterSeekExactTermState extends LuceneTestCase {

  public void testNextAfterSeekExactWithTermStateOnNewEnum() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      for (String s : new String[] {"a", "b", "c"}) {
        Document doc = new Document();
        doc.add(new StringField("f", s, Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        LeafReaderContext leaf = reader.leaves().get(0);
        Term term = new Term("f", "b");
        TermState state = TermStates.build(new IndexSearcher(reader), term, false).get(leaf).get();
        TermsEnum te = leaf.reader().terms("f").iterator();
        te.seekExact(term.bytes(), state);
        // expected: next() after the seek to "b" returns "c"; actual: "a", the field's first term
        assertEquals("c", te.next().utf8ToString());
      }
    }
  }
}
Command
cp $REPRO/TestNextAfterSeekExactTermState.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestNextAfterSeekExactTermState -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
Expected
next() returns c, the term after b.
Observed
next() returns a, the field's first term: expected:<[c]> but was:<[a]>, assertions on and off.

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

More files, environment, full output126 words

The test indexes a, b and c in field f (one segment, ByteBuffersDirectory, default codec pinned with TestUtil.getDefaultCodec(), which returns new Lucene104Codec(), not the test framework's randomized codec), takes the state of f:b with TermStates.build(new IndexSearcher(reader), term, false).get(leaf).get(), applies it to a new enum from leaf.reader().terms("f").iterator() and asserts that next() returns c. Deterministic, single thread, no random data. On 9.x, TermStates.get(leaf) returns the TermState itself: drop the trailing .get(); that port fails the same way on 9.12.3.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT) and the 10.6.0 release; 9.12.3 with the 9.x port
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

TestNextAfterSeekExactTermState > testNextAfterSeekExactWithTermStateOnNewEnum FAILED
    org.junit.ComparisonFailure: expected:<[c]> but was:<[a]>

# the same with -Ptests.asserts=false; with the recommended fix the test passes

Why it looks unintended

  • The javadoc says "Each term in the enumeration is greater than the one before it"; after the seek to b, next() returns the smaller a.
  • next()'s own comment promises that after seek(TermState) it "catches up all internal state so next() works properly".
  • TermQuery applies such states to new enums, and every other terms dictionary returns the next term.
The full argument343 words
  • Order and position. TermsEnum.java:31-32: "Term enumerations are always ordered by BytesRef.compareTo ... Each term in the enumeration is greater than the one before it." :34-35: the enum is unpositioned until you "successfully call next or one of the seek methods". :98: "Expert: Seeks a specific position by TermState".
  • The code promises it. The comment in next() (SegmentTermsEnum.java:912-917) says that if "seek(TermState) was called ... if they then call next(), this method catches up all internal state so next() works properly". The catch-up exists; it is unreachable on a new enum.
  • Lucene uses cross-instance states itself. The NOTE in the same javadoc (TermsEnum.java:105-108) calls states compatible "only iff the source and the target TermsEnum operate on the same field", which only makes sense for two enums. TermStates.build/get and TermQuery.getTermStates() are public and return states whose source enum the caller never sees; TermQuery applies them to a new enum (TermQuery.java:140-141), and so does the test framework's RandomPostingsTester (:1613, :1628).
  • The test framework's state model accepts it. AssertingTermsEnum marks the enum POSITIONED after seekExact(term, state) (AssertingLeafReader.java:454-460) and accepts next() from there (:332-343). It does not check what next() returns, so this shows how Lucene's own wrapper models the sequence, not what the javadoc promises.
  • Every other dictionary returns the successor. After the same calls (seek to banana), FST50, Direct, BlockTerms (two index variants), MemoryIndex and SimpleText return cherry; only the default block tree and BlockTreeOrds return apple. A later fact check found the same for UniformSplit and STUniformSplit.
  • History. On unreleased trunk before LUCENE-3562 (2011) the constructor loaded the root block, so next() reached the catch-up. LUCENE-3562, a performance change, made the input clone lazy and put the "fresh" branch first; nothing in it discusses next(). Stock 4.0.0 fails and 4.0.0 with only that constructor change reverted passes. In 2013 mikemccand wrote on LUCENE-3069 (#4142) that the lazy TermState seek is by design and "We only need to re-load the metadata if the caller then tries to do .next()".
The devil's-advocate check403 words

The strongest case for "works as designed": TermsEnum.java:102-103 says "Seeking by TermState should only be used iff the state was obtained from the same TermsEnum instance." Following it, the bug cannot happen, because taking a state from an enum loads a block; the only upstream test that calls next() after a TermState seek uses the same enum (TestTermsEnum.java:686); and every in-tree cross-instance caller pulls only postings or statistics. A cross-model reviewer called the trigger out of contract on this sentence alone.

Why it is weaker than it looks, though not settled: the sentence is hard to reconcile with Lucene's own use of states. TermStates.build and TermQuery.getTermStates() hand out states built from enums their callers never see, so those states can never meet the sentence, and TermQuery itself applies them to a new enum (a caller can also put its own state into a TermStates through its public constructor or register). A maintainer may still read the sentence as a precondition for calling next() after such a seek, with cross-instance states meant only for postings and statistics. The NOTE right after the sentence speaks of a "source" and a "target" enum and warns that an incompatible state "might leave this TermsEnum in undefined state"; this state meets its compatibility condition (same field, same segment), but the NOTE does not promise that next() works after a seek with another enum's state. The sentence says "should", not "must"; the next() comment promises the opposite; and the sentence came from a 2013 commit titled "javadocs" with no issue or discussion, which replaced the 2011 wording "obtained from the same IndexReader". The same day, 47 minutes before that commit, mikemccand had written on LUCENE-3069 (#4142) that the lazy TermState seek "is normal / by design" and "We only need to re-load the metadata if the caller then tries to do .next()"; only the timestamps are recorded, no link between the two is claimed. Under that wording the repro is in contract, and it already failed on 4.0.0-ALPHA and 4.0.0. None of this settles the question; the maintainers decide it. If they hold the sentence as binding, the outcome is a clearer javadoc and a corrected next() comment; either way the docs and the code disagree today. Our reading: not intended, a defect. The adjudication's confidence is medium because of this sentence, and the maintainers choose between the code fix (A) and a javadoc fix (C).

Suggested fix

Recommended A: tell a TermState-positioned enum apart from a fresh one

Add a seekedByTermState flag to the three block-tree enums: the TermState seek sets it, the other seeks clear it, and next() takes its "fresh enum" branch only when it is false. next() then reaches its existing catch-up, and the lazy clone that keeps TermQuery cheap stays. +28/-3 lines; the repro and the closest upstream suites pass.

All options and trade-offs3 options ยท 817 words

Option A was applied in scratch worktrees of 645d4fe14a20 and run with gradle (each worktree removed after the runs): once for the fact-check resolution of this page's first version, and again, fresh, for this version. It was revised after the fact checks: its first version never cleared the flag (see the trade-offs). Options B and C were not built.

A: tell a TermState-positioned enum apart from a fresh one (validated by the checks) (recommended)

In the lucene103 SegmentTermsEnum, the Lucene90 copy in backward-codecs and OrdsSegmentTermsEnum: a seekedByTermState field, set in seekExact(BytesRef, TermState), cleared at the start of the other seeks (seekExact(BytesRef) and its two-phase form, seekCeil, and in OrdsSegmentTermsEnum seekExact(long)), and tested in next(). next() then falls through to its existing catch-up, which re-seeks the term and loads its block: the same path an enum takes today when it was positioned once before the TermState seek.

// new field
private boolean seekedByTermState;

// seekExact(BytesRef) (via prepareSeekExact) and seekCeil
seekedByTermState = false;

// next()
if (in == null && seekedByTermState == false) {
  // Fresh TermsEnum; seek to first term:
  ...
}

// seekExact(BytesRef, TermState)
currentFrame = staticFrame;
seekedByTermState = true;

Trade-offs

  • Keeps the lazy clone of LUCENE-3562: a TermState seek followed only by postings or statistics, the TermQuery path, still clones nothing.
  • One boolean per enum, written by each seek; the first next() after a TermState seek pays one term lookup, as an enum positioned before the seek does today. No benchmark was run.
  • Why the other seeks clear the flag: the first version of this fix never cleared it (and reused the positioned flag in OrdsSegmentTermsEnum). That changed a sequence outside the contract, a TermState seek, then a seekExact(BytesRef) that returns false without loading a block, then next() on the now unpositioned enum (AssertingTermsEnum rejects that next()): both SegmentTermsEnum copies then continued after the old TermState term instead of restarting like a new enum, or threw (AssertionError, and ArrayIndexOutOfBoundsException without assertions) when the failed seek ended on a block without terms. The cross-model fact check pointed to this path; with the flag cleared, the sequence behaves as on stock.
  • The sandbox IDVersionSegmentTermsEnum has the same order and is not patched.

What the checks validated

  • This version's own run (fresh scratch worktree, seed DEADBEEF): stock fails; with this fix the repro, two scratch probes of its steps on BlockTreeOrds and the Lucene90 block tree, and core TestTermsEnum, TestTermsEnum2, TestTermStates, TestTermQuery, TestLucene104PostingsFormat, codecs TestOrdsBlockTree and backward-codecs TestLucene90PostingsFormat give 129 tests (126 upstream), 0 failures, 7 @Nightly skipped; with assertions off the repro and TestTermsEnum give 14 tests, 0 failures, 1 @Nightly skipped.
  • Scratch probes, 7 tests each on the default block tree, BlockTreeOrds and the Lucene90 block tree (statistics, postings and impacts right after the TermState seek; next() after postings; two TermState seeks; seekCeil; three failed seeks compared with a new enum). Stock: the two next() tests fail in all three (the bug). First fix version: the three failed-seek tests fail in both SegmentTermsEnum copies. This fix: all 21 pass.
  • Gradle with this fix, seed DEADBEEF: the repro, the 21 probe tests, two probes of the repro's steps on BlockTreeOrds and the Lucene90 block tree, and core TestTermsEnum, TestTermsEnum2, TestLucene104PostingsFormat, TestTrie, TestTermStates, TestTermQuery, codecs TestOrdsBlockTree and backward-codecs TestLucene90PostingsFormat, TestLucene99PostingsFormat, TestLucene101PostingsFormat, TestLucene101PostingsFormatV0: 251 tests (227 upstream), 0 failures, 14 @Nightly skipped, all upstream, so 213 upstream tests ran.
  • With assertions off: the repro, the probes, TestTermsEnum, TestOrdsBlockTree and TestLucene90PostingsFormat: 106 tests (82 upstream), 0 failures, 5 @Nightly skipped.
  • checkGoogleJavaFormat of the core, codecs and backward-codecs modules passes with the fix.
  • Earlier, the first fix version passed 230 of these tests, and the finder's two-file version of it passed an exhaustive harness (0 mismatches over 204,180 term sets, and over 9,196,956 sets in a larger run), a seven-dictionary differential, an independent verifier's 8 tests and 83 upstream tests. This fix was not run through the exhaustive harness.

B: open the input eagerly in seekExact(BytesRef, TermState) (not built)

Call initIndexInput() in the TermState seek, so next() never sees a null input after it.

Trade-offs

One line per copy, but it brings back the per-seek input clone that LUCENE-3562 removed on purpose, on the path every TermQuery takes for every segment. Not run, so it is unknown whether the catch-up handles an enum that has an input but no pushed frames.

C: documentation only (not built)

State in the TermsEnum javadoc that next() is not supported after seekExact(term, state) unless the state came from the same enum, and correct the comment in next().

Trade-offs

No code risk, but it leaves a silent wrong listing in a public API, and states from TermStates.build or TermQuery.getTermStates() can never meet that condition. Only if the maintainers hold the "same TermsEnum instance" sentence as binding.

Downstream note

No search result changes, but application code that pages through terms by opening a new enum per page and resuming from the previous page's TermState makes no progress (a probe's loop delivered 20,000 terms within its 200-page cap for a 2,706-term field), so such code should apply the fix or position the enum with an ordinary seek first.

How it was found

An AI tool was extending a harness that checks the real block-tree writer and reader, operation by operation, against a sorted-array reference. After reading the TermState seek code it added a walk that applies one enum's state to a fresh enum: on stock main, 10,277 of 10,700 small term sets mismatched. It then wrote a JUnit test.

The method in full164 words

Method: semi-formal (bounded-exhaustive translation validation plus code reading).

  • An AI finder was extending a harness that writes term sets with the real block-tree writer, reads them with the real reader and checks every TermsEnum operation against an exact reference (the sorted term array), over every set of 1-3 terms built from short strings and small block sizes.
  • Its TermState walks only reused the same enum. Reading the seek code, the AI tool added a walk that takes the state of a term from one enum, applies it as the first call on a second enum, then runs 12 random operations. On the stock main jar it mismatched on 10,277 of 10,700 term sets, every mismatch a next() after the TermState seek.
  • The AI tool then wrote a deterministic JUnit test that drives only public Lucene APIs, which was later reduced to the one-method test. So this was found by targeted code reading checked by an exhaustive harness, not a blind discovery by a tool.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues and PRs (incl. the JIRA archive), the mailing lists, downstream trackers or the web (searched 2026-10-26, re-checked 2026-10-30). Not fixed upstream.

Searches4 searches

No report of this bug was found, and upstream main still tests in == null before the catch-up. The nearest items are design intent and contract history, not reports: the 2013 LUCENE-3069 comment, the 2013 "javadocs" commit, the introducing LUCENE-3562, and LUCENE-3260 (#4333), a different next()-after-seek bug in MultiTermsEnum that was fixed as a bug in 2011.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr, k-NN, lucenenet)2026-10-26174 query lines through a scripted prior-report search plus 84 GraphQL searches, every result read; class, method, code-comment, javadoc and symptom terms; 56 items fetched and read, including all 7 high-overlap hits of an earlier 1001-hit passno report; closest #4142 / LUCENE-3069 (design intent), #4636 / LUCENE-3562 (introducing change), #4333 / LUCENE-3260 (different bug, precedent)
Open PRs2026-10-26changed-file lists of all 413 open apache/lucene PRs; diffs of those touching block-tree enum files grepped#13253, #14716, #14894, #15556 and #12509 touch block-tree enum files but not next() or the TermState seek; #13192 and #16407 change only SegmentTermsEnumFrame (found by the first fact check, re-checked open and unchanged on 2026-10-30)
Mailing lists, JIRA, web2026-10-26dev@, java-user@, issues@, commits@; JIRA queries (two broad ones truncated at 50 results); web queries (titles only)only the 2011 dev@ thread "Proper use of TermsEnum.seek?" (the usage pattern, no failure reported) and the commit mails of the 2013 "javadocs" change (no replies)
Re-check for this page2026-10-30 (three times)upstream main HEAD and the last commit on each block-tree enum file and TermsEnum; issues and PRs created since 2026-10-25 matching the method and class namesunchanged (no commit on these files since 645d4fe14a20; main at 2f424fd428da); nothing new

History

Introduced in 2011 by LUCENE-3562 (9e27723b3702), a performance change that made the input clone lazy and put the fresh-enum test first. Every release from 4.0.0-alpha to 10.6.0 has it, and so does main; no release was ever unaffected.

Versions tested8 rows

LUCENE-3562 "stop caching thread-private TermsEnums in Terms" (mikemccand, svn r1203294, 2011-11-17; the JIRA issue is now #4636, before GitHub, no PR) commented out the root-block load in the SegmentTermsEnum constructor, made the input clone lazy "so that consumers that just pull a TermsEnum to seekExact(TermState) don't pay this cost", and added the "Fresh TermsEnum" branch ahead of the catch-up. Later commits moved, copied, forked or restructured the code and kept that order (a 2013 fork and its removal, LUCENE-5675 and the sandbox IDVersion fork in 2014, LUCENE-5819 BlockTreeOrds in 4.10.0, LUCENE-5969 backward codecs in 2014, LUCENE-9705 lucene90 in 2021, #14333 and #14563 lucene103 in 2025, #15712 in 2026). Block tree existed without the bug only on unreleased trunk from 2011-08-20 to 2011-11-17; 3.6.2 has neither the API nor block tree. A static scan of the 123 release tags 4.0.0-alpha to 10.5.2 in the history clone finds the same order in every one, and the 10 releases not tagged there (8.8.2, 8.9.0 to 8.11.4, 10.6.0) were all run: together all 133 releases from 4.0.0-alpha to 10.6.0.

The release rows ran the minimal test (10.6.0), a 9.x port of it (9.12.3), the finder's JUnit (10.5.2, 10.3.0, 10.2.2, 9.12.3) or a portable main() version of the same steps (on 4.0.0-ALPHA, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.2.2, 10.3.0 and 10.5.2, and on every release from 8.8.2 to 8.11.3); all on OpenJDK 25.0.3, Linux aarch64, release jars SHA-1 checked.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 (unreleased 11.0) affectedfails, -ea and -da, gradle and standalone: a instead of c; BlockTreeOrds and the Lucene90 block tree fail too
10.6.0 affectedfails, -ea and -da (release jars)
10.3.0 to 10.5.2 affectedfail (10.3.0 and 10.5.2 run, 10.5.2 also with -da and with BlockTreeOrds; the others by static scan)
10.0.0 to 10.2.2 affectedfail (10.0.0 and 10.2.2 run; the Lucene90 block tree was the default then)
9.x affectedfail (9.0.0 and 9.12.3 run; on 9.12.3 also the 9.x port of the minimal test, the finder's JUnit and BlockTreeOrds)
4.0.0-alpha to 8.11.4 affectedfail (4.0.0-ALPHA, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0 to 8.11.4 run; the others by static scan): next() = apple instead of cherry
4.0.0 with only the LUCENE-3562 constructor change reverted revert checkpasses: next() = cherry
3.6.2 n/adoes not compile: no TermsEnum, TermState or block tree

Independent checks

Every check that ran a test reproduced it and agreed on the root cause. The one dissent is about the contract: a cross-model reviewer (OpenAI GPT-5.6) called the trigger out of contract on the "same TermsEnum instance" javadoc sentence, two were uncertain; the adjudication confirmed it with medium confidence.

Checks and verdicts760 words

Nobody disputes that the code misbehaves: every check that ran a test reproduced it, and all agree on the root cause. The only dissent is about the contract, the javadoc sentence discussed above; that is why the adjudication's confidence is medium.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (finder) candidateExhaustive harness: 10,277 of 10,700 term sets mismatch on stock, 0 of 204,180 with the fix; its JUnit fails 1 of 3 on main (-ea, -da), 10.5.2, 10.3.0, 10.2.2 and 9.12.3; a seven-dictionary differential isolates the two block-tree enums.
Cross-model review, refute lens (OpenAI GPT-5.6, executed) not real, highReproduced on main with -ea, -da and three seeds; root cause and fix agreed; called the trigger out of contract on the "same TermsEnum instance" sentence alone (its same-instance control passes).
Cross-model review, intended lens (OpenAI GPT-5.6, executed) uncertain, mediumReproduced; root cause and fix agreed; judged the contract self-contradictory.
Independent reproducer realOwn JUnit fails 4 of 8 on main (-ea, -da), 10.5.2, 10.2.2 and 9.12.3, BlockTreeOrds too; 4 controls pass; 8 of 8 pass with the fix.
Cross-model review, root-cause lens (OpenAI GPT-5.6) uncertain, mediumIts own test fails on main; first bad transition agreed; pointed out the Lucene90 and sandbox copies; separate re-runs fail on stock and pass with the fix.
Intended-behaviour devil's advocate real, mediumIts TermStates-path test fails 1 of 1; traced the 2011 to 2013 javadoc history and found no rationale for the narrowing.
Adjudication confirmed, mediumRe-ran the independent reproduction's test (8 run, 4 failed), the finder's with -da (3 run, 1 failed) and the intended-behaviour check's (1 of 1 failed).
Minimal repro verifiedThe one-method test fails on main 645d4fe14a20 with -ea (two seeds) and -da; passes with the core hunk of the fix.
This page's runs reproduced; first fix version validatedGradle stock fail (-ea, -da); 10.6.0 and the 9.12.3 port fail; BlockTreeOrds and Lucene90 probes fail on main; the first version of fix A passed 230 tests.
Fact checks of this page's first version (an independent checker; a reviewer from another model family, OpenAI GPT-5.6) real; wording errors foundBoth found the bug real; the independent checker re-ran the repro, the probes and the first fix version. They found a stale rank, evidence tags broader than the runs, an incomplete caller list, a too-broad TermStates sentence and a flag the first fix version never cleared. All are corrected on this page.
Resolution of the first fact checks fix A revised and validatedThe 8 releases that had been neither scanned nor run (8.8.2 to 8.11.3) fail; the first fix version changes an out-of-contract failed-seek sequence in both SegmentTermsEnum copies; with the revised fix, 251 tests (227 upstream) give 0 failures with assertions on (14 @Nightly skipped) and 106 with them off (5 skipped).
This version's own runs reproduced; fix A validatedFresh scratch worktree of 645d4fe14a20, seed DEADBEEF: stock fails; with this fix the repro, two scratch probes of its steps on BlockTreeOrds and the Lucene90 block tree, and core TestTermsEnum, TestTermsEnum2, TestTermStates, TestTermQuery, TestLucene104PostingsFormat, codecs TestOrdsBlockTree and backward-codecs TestLucene90PostingsFormat give 129 tests (126 upstream), 0 failures, 7 @Nightly skipped; with assertions off the repro and TestTermsEnum give 14 tests, 0 failures, 1 @Nightly skipped.
Fact checks of this version (an independent checker; a reviewer from another model family, OpenAI GPT-5.6, read-only; a diagram check) real; wording, citations and one diagram count fixedAll three found the bug real as described. In a fresh scratch worktree of 645d4fe14a20 the independent checker re-ran the page's gradle command with -ea and -da, both probes and fix A's suites (129 and 14 tests, 0 failures), and its own probe of the a/b/c, five-term and 3,000-term shapes on main (-ea, -da) and on 10.6.0. They found citations of the first fact checks that pointed at files since replaced, release coverage worded more broadly than the runs, an API-wide sentence that holds only for block tree, overclaims about search results, the javadoc sentence and the paging loop, and diagrams that counted 14 skipped nightly-only tests as passed. All are corrected on this page and the diagrams were re-rendered.
Resolution of this version's fact checks all errors fixed; one caveat declined with a reasonEvery error of the three checks was accepted and fixed. One suggested caveat, that the default codec is not really pinned, was declined: the test sets TestUtil.getDefaultCodec(), which returns new Lucene104Codec(), and the page now says so.

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

Finding record

LFV-157, snapshot of 2026-11-06

Registry title
Block-tree TermsEnum.next() after seekExact(term, TermState) on a never-positioned enum restarts at the field's first term instead of returning the next term
Area
codecs / block-tree terms dictionary (Lucene103 SegmentTermsEnum; also BlockTreeOrds and the Lucene90 block tree in backward-codecs)
Priority
Low, rank 598: Block-tree TermsEnum.next() after seekExact(term, TermState) on a fresh enum restarts at the field's first term, silent but only for custom code driving that Expert call sequence, under a disputed 'same TermsEnum instance' contract.
Impact class
silent wrong results
Impact note
Application or downstream code that positions a brand-new block-tree TermsEnum (default codec, BlockTreeOrds, or the Lucene90 block tree that was the default in 9.x-10.2) with seekExact(term, TermState), using a state taken from another enum of the same field and segment (typically from TermStates or TermQuery.getTermStates), and then calls next() silently gets the field's first term and a second listing of the whole field, including the terms before the sought one, with -ea and -da; default codec but an Expert/experimental call sequence that no in-tree query and none of the downstream code read performs, so no wrong search result is known; no scale needed.
Summary
You take the TermState of a term (say 'banana') from one TermsEnum, or from TermStates the way TermQuery does, and use it to position a brand-new TermsEnum of the same field and segment with seekExact(term, state); term() and docFreq() then correctly report 'banana'. You expect next() to return the following term ('cherry'), as the comment inside next() promises and as every other terms dictionary does. Instead, with the default block-tree terms dictionary, next() returns the field's first term ('apple') and iteration starts over from the beginning, because next() mistakes the not-yet-loaded enum for a fresh one and throws the TermState position away.
Introduced
9e27723b3702 (LUCENE-3562 'stop caching thread-private TermsEnums in Terms', svn r1203294 on trunk, by mikemccand; the JIRA issue is now apache/lucene#4636; pre-GitHub, no PR): it moved the root-block load out of the SegmentTermsEnum constructor, made the IndexInput clone lazy ('so that consumers that just pull a TermsEnum to seekExact(TermState) don't pay this cost') and added the 'if (in == null) // Fresh TermsEnum; seek to first term' branch at the top of next(), ahead of the staticFrame catch-up re-seek [read: git show, git blame, git log -S 'Fresh TermsEnum' lists it as the earliest commit; exec: stock 4.0.0 fails and the same 4.0.0 with only that constructor hunk reverted passes; the parent commit itself was not built]; carried into every later copy unchanged in substance: LUCENE-5675 (9ebab12f647, 2014, SegmentTermsEnum moved to its own file), LUCENE-5819 (BlockTreeOrds), LUCENE-9705 (lucene90), #14333 (878e23f7739, 2025, lucene103 trie index) [read] (2011-11-17)
First affected version
4.0.0-alpha [exec] (the first release containing the commit; first GA release 4.0.0 [exec]); no release was ever unaffected: 3.6.2 and earlier have neither the TermsEnum/TermState API nor the block-tree terms dictionary [exec: the repro does not compile], and block tree existed without the bug only on unreleased trunk from 2011-08-20 to 2011-11-17 [read]
Affected versions
every release from 4.0.0-alpha through 10.6.0 and main ea57360912ed [exec: the 10.6.0 release jars with -ea and -da (the bug doc's runs), 4.0.0-ALPHA, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.2.2, 10.3.0, 10.5.2 and main all fail; 9.12.3, 10.5.2 and main also with -da; controls pass on 4.0.0, 9.12.3 and main] [read: a static check of all 123 release tags 4.0.0-alpha..10.5.2 in the history clone (8.9.0-8.11.x are not tagged there) finds the fresh-enum branch ahead of the catch-up in every one]; BlockTreeOrds (lucene/codecs) since 4.10.0 [exec: 9.12.3, 10.5.2, main]; the Lucene90 block tree, the default terms dictionary in 9.x-10.2, fails on 9.12.3 and 10.2.2 [exec] and has the same ordering in backward-codecs on main, which reads older indexes [read, not executed on main]; not fixed upstream: main, branch_10x, branch_10_6 and branch_9_12 still have the ordering and no open PR changes it [read 2026-10-26]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: 174 query lines: a scripted prior-report search over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 84 GitHub GraphQL searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, opensearch-project/k-NN and apache/lucenenet with every result read; 56 items fetched and read, including all 7 high-overlap hits of the finder's earlier 1001-hit pass; the changed files of all 413 open apache/lucene PRs; git log -S over the full history; the intended-behaviour check's targeted searches): no issue, PR, review comment, JIRA entry, list post or downstream item reports it; not fixed upstream: next() still tests 'in == null' before 'currentFrame == staticFrame' in the lucene103 SegmentTermsEnum on main, branch_10x and branch_10_6, in OrdsSegmentTermsEnum on all four branches and in the Lucene90 SegmentTermsEnum (backward-codecs on main and 10.x, core on branch_9_12), and no open PR changes that code (#13253, #14716, #14894, #15556 and #12509 touch block-tree enum files but not next() or seekExact(BytesRef, TermState)); related, not reports of this bug: LUCENE-3069 (#4142; a 2013 comment by mikemccand that a TermState seek only needs to re-load state 'if the caller then tries to do .next()': design intent that supports the expected behaviour), commit 98c38acfe56 (svn r1503790, 2013, message 'javadocs', no issue, no list replies; narrowed the javadoc from 'same IndexReader' to 'same TermsEnum instance'), LUCENE-3562 (#4636, the introducing change; nothing on next()), LUCENE-3260 (#4333, a different next()-after-seekExact(BytesRef) bug in MultiTermsEnum, fixed in 2011: precedent), the dev@ thread 'Proper use of TermsEnum.seek?' (2011, a user calling seek(BytesRef, TermState) then next() before block tree existed; no failure reported), LUCENE-7885 (#8936, resolved Invalid, different), #9708 and #9339 (FilterTermsEnum delegation, different); gaps: GitHub REST search was throttled (12 REST queries, the rest through GraphQL), two broad JIRA queries were truncated at 50 of 62 and 50 of 58 (the targeted ones were complete), web search returned titles and URLs only
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28; on 2026-10-30 upstream main 2f424fd428da has the same block-tree files): the minimal test with gradle in fresh scratch worktrees (removed after the runs), stock with assertions on and off, and with the recommended fix together with the closest core, codecs and backward-codecs suites; a standalone runner on main and on the published 10.6.0 jars with -ea and -da, a 9.x port on 9.12.3; earlier runs of the finder's and verifiers' tests on 10.5.2, 10.3.0, 10.2.2 and 9.12.3, and a portable main() version on 4.0.0-ALPHA, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.2.2, 10.3.0 and 10.5.2 and on every release from 8.8.2 to 8.11.3; the other releases from 4.0.0-alpha to 10.5.2 are covered by a static scan of their code. JDK OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/SegmentTermsEnum.java:81-85 (initIndexInput: lazy clone of 'in'), :892-922 (next(): 'if (in == null) { // Fresh TermsEnum; seek to first term' at :893 overwrites currentFrame before the 'if (currentFrame == staticFrame)' catch-up at :911; comment at :912-917 'if they then call next(), this method catches up all internal state so next() works properly'), :1027-1046 (seekExact(BytesRef, TermState): currentFrame = staticFrame, 'in' left null)
  • a Lucene main checkout lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java:906 ('in == null') before :932 (staticFrame catch-up); lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene90/blocktree/SegmentTermsEnum.java:983 before :1008
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/TermsEnum.java:98-108 (seekExact(BytesRef, TermState) javadoc: 'Expert: Seeks a specific position by TermState'; :102-103 'Seeking by TermState should only be used iff the state was obtained from the same TermsEnum instance'; :105-108 'compatible only iff the source and the target TermsEnum operate on the same field')
The finding in 30 seconds
  • Lucene main 645d4fe14a20: lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/SegmentTermsEnum.java:60-69, :81-85, :892-898, :911-922, :1027-1046; SegmentTermsEnumFrame.java:147, :167
  • Lucene main 645d4fe14a20: lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java:906, :932, :1079; lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene90/blocktree/SegmentTermsEnum.java:983, :1008, :1124; lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/idversion/IDVersionSegmentTermsEnum.java:1018-1019
  • Lucene main 645d4fe14a20: lucene/core/src/java/org/apache/lucene/search/TermQuery.java:136-141; lucene/core/src/java/org/apache/lucene/index/TermStates.java:96, :178
Who is affected
  • Lucene main 645d4fe14a20: lucene/core/src/java/org/apache/lucene/search/TermQuery.java:141, :213; lucene/core/src/java/org/apache/lucene/index/TermsEnum.java:37
  • Lucene main 645d4fe14a20: lucene/sandbox/src/java/org/apache/lucene/sandbox/search/PhraseWildcardQuery.java:288, TermAutomatonQuery.java:421, CombinedFieldQuery.java:389; lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:306; lucene/core/src/java/org/apache/lucene/index/FilterLeafReader.java:239 (git grep of two-argument seekExact callers)
Minimal repro
  • Lucene main 645d4fe14a20: lucene/test-framework/src/java/org/apache/lucene/tests/util/TestUtil.java:1330-1331 (getDefaultCodec returns new Lucene104Codec())
Why it looks unintended
  • Lucene main 645d4fe14a20: lucene/core/src/java/org/apache/lucene/index/TermsEnum.java:31-32, :34-35, :98-108; lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/SegmentTermsEnum.java:912-917; lucene/core/src/java/org/apache/lucene/search/TermQuery.java:140-141; lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomPostingsTester.java:1613, :1628; lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:332-343, :454-460; lucene/core/src/test/org/apache/lucene/index/TestTermsEnum.java:686; lucene/core/src/java/org/apache/lucene/index/TermStates.java:77, :96, :139, :150, :178, :193, :203
Suggested fix
  • Lucene main 645d4fe14a20: lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/SegmentTermsEnum.java:81-85, :892-922, :1027-1046; lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnum.java:906, :932, :1079; lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene90/blocktree/SegmentTermsEnum.java:983, :1008, :1124
  • Lucene main 645d4fe14a20: lucene/core/src/java/org/apache/lucene/index/TermsEnum.java:58-62; lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:334-335, :417-420; lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/SegmentTermsEnum.java:253-256, :427-434, :465-471, :520-523

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 page: a summary card (PNG), every on-screen element checked against the code and an executed probe. The fix box and the fix section show the lucene103 lines of the recommended fix (option A: the seekedByTermState flag, set by the TermState seek, cleared by seekExact(BytesRef) and seekCeil, and tested in next()); the same lines go into BlockTreeOrds and the Lucene90 copy. Its test counts are those of the fix's gradle runs: 227 upstream tests with assertions on (14 of them nightly-only and skipped) and 82 with them off (5 skipped), 0 failures.

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.