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.
- A
TermsEnumwalks 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. - A
TermStateis a saved position of one term. The block-tree dictionary'sseekExact(term, state)jumps straight to it without searching the terms dictionary (the javadoc allows this for low-level implementations).TermQuerybuilds such states withTermStates.buildand applies each one to a new enum of its segment to read the term's postings. - 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.
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.- Index three documents whose field
fholdsa,bandc(one segment, default codec), and take the TermState off:bwithTermStates.build(...), asTermQuerydoes. - Open a new enum on
fand callseekExact("b", state).term()reportsbanddocFreq()1, as expected. - Call
next(). You expectc: terms come in increasing order, and every other terms dictionary returns the next term after the same calls. - You get
a, the field's first term, with no error, assertions on or off. The next calls returnb,c,null: the whole field again, including the terms beforeb. - 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.
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
SegmentTermsEnumconstructor setscurrentFrame = staticFrameand does not clone the terms input (SegmentTermsEnum.java:60-69, the root-block load at:68-69commented out);initIndexInput()clones it lazily (:81-85), when a frame first prefetches or loads a block (SegmentTermsEnumFrame.java:147,:167). seekExact(BytesRef, TermState)(:1027-1046) setscurrentFrame = staticFrame(:1035), copies the state and the term, and loads no block, soinstaysnull.next()(:892) first testsif (in == null) { // Fresh TermsEnum; seek to first term(:893) and makes the root frame the current frame in place ofstaticFrame(:896).- Only then comes
if (currentFrame == staticFrame)(:911-922), whose comment says that afterseek(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 replacedcurrentFrame, so it never runs andnext()returns the first term of the root block. - The same order is in BlockTreeOrds (
OrdsSegmentTermsEnum.java:906before:932) and in the Lucene90 block tree in backward-codecs (SegmentTermsEnum.java:983before:1008); the sandboxIDVersionSegmentTermsEnumhas it too (next()at:1016, its fresh-enum branch at:1018-1019).
- Terms a, b, c, seek to b:
term()is b anddocFreq()1, then thenext()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 thenext()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, andterm(),docFreq(),totalTermFreq(),postings()andimpacts()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
TermsEnumdirectly: it positions a brand-new block-tree enum withseekExact(term, state), the state taken fromTermStates,TermQuery.getTermStates()or a saved (term, state) pair, and then callsnext(), 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 andSpanTermQuery; in sandboxPhraseWildcardQuery,TermAutomatonQueryand the sandboxCombinedFieldQuery.FuzzyTermsEnumandFilterLeafReaderonly 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 callsnext()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.experimentalclass (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 bynext(), before block tree existed.
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.
/*
* 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());
}
}
}
}
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()returnsc, the term afterb.- Observed
next()returnsa, the field's first term:expected:<[c]> but was:<[a]>, assertions on and off.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT) and the 10.6.0 release; 9.12.3 with the 9.x port |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
TestNextAfterSeekExactTermState > testNextAfterSeekExactWithTermStateOnNewEnum FAILED
org.junit.ComparisonFailure: expected:<[c]> but was:<[a]>
# the same with -Ptests.asserts=false; with the recommended fix the test passes- 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 afterseek(TermState)it "catches up all internal state so next() works properly".TermQueryapplies 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/getandTermQuery.getTermStates()are public and return states whose source enum the caller never sees;TermQueryapplies them to a new enum (TermQuery.java:140-141), and so does the test framework'sRandomPostingsTester(:1613,:1628). - The test framework's state model accepts it.
AssertingTermsEnummarks the enumPOSITIONEDafterseekExact(term, state)(AssertingLeafReader.java:454-460) and acceptsnext()from there (:332-343). It does not check whatnext()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 discussesnext(). 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).
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.
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
TermQuerypath, 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
positionedflag inOrdsSegmentTermsEnum). That changed a sequence outside the contract, a TermState seek, then aseekExact(BytesRef)that returns false without loading a block, thennext()on the now unpositioned enum (AssertingTermsEnumrejects thatnext()): bothSegmentTermsEnumcopies then continued after the old TermState term instead of restarting like a new enum, or threw (AssertionError, andArrayIndexOutOfBoundsExceptionwithout 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
IDVersionSegmentTermsEnumhas 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, codecsTestOrdsBlockTreeand backward-codecsTestLucene90PostingsFormatgive 129 tests (126 upstream), 0 failures, 7@Nightlyskipped; with assertions off the repro andTestTermsEnumgive 14 tests, 0 failures, 1@Nightlyskipped. - 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 twonext()tests fail in all three (the bug). First fix version: the three failed-seek tests fail in bothSegmentTermsEnumcopies. 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, codecsTestOrdsBlockTreeand backward-codecsTestLucene90PostingsFormat,TestLucene99PostingsFormat,TestLucene101PostingsFormat,TestLucene101PostingsFormatV0: 251 tests (227 upstream), 0 failures, 14@Nightlyskipped, all upstream, so 213 upstream tests ran. - With assertions off: the repro, the probes,
TestTermsEnum,TestOrdsBlockTreeandTestLucene90PostingsFormat: 106 tests (82 upstream), 0 failures, 5@Nightlyskipped. checkGoogleJavaFormatof 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.
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.
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.
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.
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
TermsEnumoperation 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr, k-NN, lucenenet) | 2026-10-26 | 174 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 pass | no report; closest #4142 / LUCENE-3069 (design intent), #4636 / LUCENE-3562 (introducing change), #4333 / LUCENE-3260 (different bug, precedent) |
| Open PRs | 2026-10-26 | changed-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, web | 2026-10-26 | dev@, 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 page | 2026-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 names | unchanged (no commit on these files since 645d4fe14a20; main at 2f424fd428da); nothing new |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 (unreleased 11.0) | affected | fails, -ea and -da, gradle and standalone: a instead of c; BlockTreeOrds and the Lucene90 block tree fail too |
| 10.6.0 | affected | fails, -ea and -da (release jars) |
| 10.3.0 to 10.5.2 | affected | fail (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 | affected | fail (10.0.0 and 10.2.2 run; the Lucene90 block tree was the default then) |
| 9.x | affected | fail (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 | affected | fail (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 check | passes: next() = cherry |
| 3.6.2 | n/a | does not compile: no TermsEnum, TermState or block tree |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (finder) | candidate | Exhaustive 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, high | Reproduced 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, medium | Reproduced; root cause and fix agreed; judged the contract self-contradictory. |
| Independent reproducer | real | Own 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, medium | Its 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, medium | Its TermStates-path test fails 1 of 1; traced the 2011 to 2013 javadoc history and found no rationale for the narrowing. |
| Adjudication | confirmed, medium | Re-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 | verified | The 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 validated | Gradle 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 found | Both 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 validated | The 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 validated | Fresh 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 fixed | All 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 reason | Every 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. |
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 471 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,566 words
- 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 main2f424fd428dahas 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-eaand-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 portablemain()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.
- 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 :1008a 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, :167Lucene 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-1019Lucene 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:37Lucene 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, :1124Lucene 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
- TestNextAfterSeekExactTermState.java (JUnit for Lucene's build):
repro/LFV-157/src/org/apache/lucene/index/TestNextAfterSeekExactTermState.java(50 lines)
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.
assets/LFV-157/bug.png(471 KB)
