The shallow advance moves the reader's streams to the target block but leaves the document and its decoded buffers on the old block until the next nextDoc()/advance(). Reads of the current document in between mix the two.
- A term's postings are stored in blocks of 256 documents. Each block also stores its "impacts": the best (frequency, length norm) pairs inside it, from which a scorer derives the block's maximum possible score.
ImpactsEnum.advanceShallow(target)moves only to the block that containstarget, so thatgetImpacts()describes it. The iterator'sdocID()does not change.- If that block cannot produce a competitive hit, the scorer skips it with
advance(); if it can, it advances into it. After a shallow advance past the current document, Lucene's own scorers move the iterator before reading a document's data again.
t: the shallow advance moves the reader to block 1 while the document stays on doc 0, and the lazy freq() sends it back, so advance(256) lands on 261. Select the still to open it at full size. Generated with AI, checked against the code.- You index 1,200 documents that all contain
t, take anImpactsEnumfromTermsEnum#impacts, and callnextDoc(): you are on doc 0. The postings are stored in blocks of 256 documents. - You call
advanceShallow(256)to peek at the next block's score bounds;docID()stays 0. The javadoc now forbids onlynextDoc()jumps andadvance()targets below 256. - You read
freq()of doc 0 (it returns 1, correct) and calladvance(256). Every document hast, so you expect 256. - You get 261. The peek had already moved the reader's file position into block 1; the lazy
freq()seeked back to block 0's freqs and left the position at the start of block 1's skip data, so the next refill decoded those bytes as doc ids. Without thefreq()call: 256. - In two 4,000-document variants, other reads of the current document go wrong too: with
tin every third document, a position of 5 instead of 0; withtin docs 0-255 and then in every third document, adocIDRunEnd()of 3328 although doc 256 does not match. Lucene's own queries never read in this order.
Only code that drives an ImpactsEnum (or a TermScorer) itself for custom block-max scoring and reads the current document after peeking ahead: it gets silently wrong data or a wrong next document. Default codec, every release since 8.0.0. Stock queries, scorers and CheckIndex move the iterator after such a peek before reading.
Mechanism, terms and numbers737 words
ImpactsSource#advanceShallow(target) lets a caller look at score upper bounds for documents at or after target while docID() stays where it is. Its javadoc lists what becomes illegal afterwards (nextDoc() while docID() < target - 1, advance() below target) and says nothing about the current document. The default codec, however, moves its internal streams to the target block during the shallow advance and refills only at the next nextDoc()/advance(). So code that follows the written contract and reads the current document after peeking ahead gets another block's positions or run end, and a lazily decoded freq() (whose value is still right) breaks the next legal advance(): in the repro, advance(256) returns 261 although every document contains the term.
The codec side is intended (the javadoc calls the shallow advance "cheaper than calling advance", and no checked in-tree caller reads the current document after a shallow advance past it without moving first; AssertingScorer already rejects the Scorer-level form). What is missing is the precondition in the javadoc and a check in the test framework's AssertingImpactsEnum, which passes freq() and nextPosition() through and whose eager freq() hides the symptom from randomized tests. The gap exists in every release from 8.0.0 to 10.6.0 and on main.
- Freqs are decoded lazily. A full-block refill decodes the doc ids, remembers where the block's freqs start (
freqFP) and skips over them (Lucene104PostingsReader.java:626-631);freq()seeks back and decodes them on first use (:581-589). AfternextDoc()on doc 0, the doc stream sits at the start of block 1's skip data (skip data is stored in front of each block). - The shallow advance moves the streams, not the document.
advanceShallow(256)seestarget > level0LastDocID(255) and runsdoAdvanceShallow/skipLevel0To, which reads block 1's skip data from the doc stream (and, with positions, seeks the position stream), then setsneedsRefilling = true(:879-895,:818-876).docand the decoded buffers stay on block 0. The field comment: "true if we shallow-advanced to a new block that we have not decoded yet" (:406-407). - The lazy
freq()moves the doc stream back. It seeks to block 0's freqs and decodes them: the value for doc 0 is right (1), but the stream is left at block 1's skip header again. - The next legal
advance()decodes the wrong bytes.advance(256)refills becauseneedsRefillingis set (:924-931), from the current stream position, so it reads the skip header as the block's bits-per-value byte and packed doc deltas (:591-596) and lands on 261. - Other reads.
nextPosition()reads the position stream, which already points at the target block;docIDRunEnd()combines the old block's dense bit set with the moved level bounds (:1099-1129); that path exists only when the current block is a fully dense bit set (UNARYencoding), otherwise the default one-doc run is returned, which is correct. The study's security probe also sawintoBitSet()not return within 20 s (5 of 6 cases) andnextPostings()skip matches with assertions off;nextPostingsstarts withassert needsRefilling == false(:1036).
For nextDoc()/advance(), AssertingImpactsEnum checks only the two documented limits (AssertingLeafReader.java:701-713) and passes freq() and nextPosition() straight through (:671-679). Its inner AssertingPostingsEnum calls freq() eagerly in every nextDoc()/advance() (:502, :521), which decodes the freqs before any shallow advance: through the wrapper, the repro's advance(256) returns 256 and no assertion fires. SlowImpactsEnum keeps the current document valid.
- Minimal repro (1,200 docs, all with
t, freq 1 to 5):advance(256)afterfreq()returns 261; withoutfreq(), 256. - Study tests (two 4,000-document indexes,
advanceShallow(3000)): withtin every third document, positions of doc 0[5]instead of[0], of doc 3[2, ...]instead of[1, 4], andfreq()thenadvance(3000)= 3005, a document withoutt; with the term in docs 0-255 (one dense block) and then in every third document,docIDRunEnd()from doc 0 = 3328 although doc 256 does not match. - Other layouts:
EOFException,IndexOutOfBoundsException, or a doc id beyondmaxDoc(2897 with 1,200 docs on 9.12.x). - One level up, a
TermScorerinTOP_SCORESmode:advanceShallow(256),score(), thenadvance(256)also returns 261 (AssertingScorerrejects thatscore()withSHALLOW_ADVANCING).
Exposure and likelihood240 words
- Who. Code that uses
ImpactsEnumdirectly (from the publicTermsEnum#impacts,TermsEnum.java:172-177) and readsfreq(), positions,docIDRunEnd()or bulk postings of the current document afteradvanceShallow(target)into a later block. The same order throughScorer#advanceShallowandscore()on aTermScorergives the same result; that javadoc says the call "doesn't modify the current docID()" (Scorer.java:66). - What goes wrong. Wrong per-document data, then a wrong next document (one without the term), an
EOFException, or a doc id beyondmaxDoc. The same with assertions on and off. - Who is not hit.
TermQuery,PhraseQuery,SynonymQuery,FeatureQuery, WAND, MaxScore, block-max conjunctions,ReqOptSumScorerandCheckIndexmove the iterator after a shallow advance past the current document before reading (CheckIndexreads afteradvanceShallow(docID()), which is harmless); non-scoring clauses get plain postings, at most wrapped in aSlowImpactsEnumwhoseadvanceShallowdoes nothing; the study's security probe found stock top-scores queries over 20 fixed and 400 random shapes matching exhaustive scoring. No caller was found in Elasticsearch, OpenSearch or Solr main (not exhaustive). - Default vs opt-in. Default codec, no configuration; it takes direct use of an
@lucene.experimentalAPI in an order the javadoc does not forbid. The freq-then-advance symptom needs a second full 256-doc block after the current one (terms with 334 and 500 postings were fine, 512 to 2,500 were not). - Scale and age. 1,200 documents suffice; the gap is in every release from 8.0.0.
One JUnit test, one method, deterministic input: 1,200 documents that all contain t, default codec, ByteBuffersDirectory. It calls nextDoc(), advanceShallow(256), freq(), advance(256) and asserts 256. Public methods only, though ImpactsSource is @lucene.internal and ImpactsEnum @lucene.experimental. Without the freq() line it passes.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.BytesRef;
public class TestAdvanceShallowThenFreqThenAdvance extends LuceneTestCase {
public void testAdvanceAfterFreq() throws IOException {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w =
new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
for (int i = 0; i < 1200; i++) {
Document doc = new Document();
doc.add(new TextField("f", "t ".repeat(1 + i % 5), Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(w)) {
TermsEnum termsEnum = getOnlyLeafReader(reader).terms("f").iterator();
assertTrue(termsEnum.seekExact(new BytesRef("t")));
ImpactsEnum impacts = termsEnum.impacts(PostingsEnum.FREQS);
assertEquals(0, impacts.nextDoc());
impacts.advanceShallow(256);
impacts.freq();
// every doc has t, and the advanceShallow javadoc allows advance(256): expected 256, got 261
assertEquals("advance(256) after freq()", 256, impacts.advance(256));
}
}
}
}
cp $REPRO/TestAdvanceShallowThenFreqThenAdvance.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestAdvanceShallowThenFreqThenAdvance -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
- Expected
advance(256)returns 256: every document containst, and the javadoc allowsadvance(256)afteradvanceShallow(256).- Observed
advance(256)returns 261 (expected:<256> but was:<261>), with assertions on and off; without thefreq()call it returns 256.
More files, environment, full output354 words
- Deterministic input.
ByteBuffersDirectory,IndexWriterConfig()with the defaultStandardAnalyzer, the default codec pinned, norandom()in the test (the test framework still randomizes locale, time zone and the like); seedsDEADBEEF,5EED1234and three more give the same failure, with assertions on and off. - Control. The same test without the
impacts.freq()line passes:advance(256)returns 256. - It transcribes the current javadoc. Once the precondition is documented, this call order is off-contract: the test is the reason for the sentence, not a regression test for a code change, and no correct codec change is expected to make it pass.
- Regression test for the assertion half (optional): this same test with the reader wrapped in
AssertingDirectoryReaderand the last two lines replaced byexpectThrows(AssertionError.class, impacts::freq); assertEquals(256, impacts.advance(256));(methodtestFreqAfterAdvanceShallowIsRejected). On stock main it fails ("Expected exception AssertionError but no exception was thrown"); with option B it passes. It requires assertions: run it with-Ptests.asserts=true(without it the build enables assertions for only about 75% of seeds). - Releases. The same file fails unchanged on the 10.6.0 and 10.5.2 release jars (261) and on 9.12.0 to 10.2.0 (2897 or 258); it passes on 9.11.1, where the freq value itself is wrong instead (Version history).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on the 10.6.0 and 10.5.2 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp $REPRO/TestAdvanceShallowThenFreqThenAdvance.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestAdvanceShallowThenFreqThenAdvance -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestAdvanceShallowThenFreqThenAdvance > testAdvanceAfterFreq FAILED
java.lang.AssertionError: advance(256) after freq() expected:<256> but was:<261>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:465540CFEC35E1F9]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:647)
at org.apache.lucene.index.TestAdvanceShallowThenFreqThenAdvance.testAdvanceAfterFreq(TestAdvanceShallowThenFreqThenAdvance.java:48)
:lucene:core:test (FAILURE): 1 test, 1 failure
# -Ptests.asserts=false: the same failure
# control (standalone): the same test without the freq() line passes, advance(256) = 256 (assertions on and off)
# release jars (standalone): 10.6.0 and 10.5.2: 261; 10.1.0, 10.2.0: 258; 9.12.0, 9.12.3, 10.0.0: 2897; 9.11.1: passes
# with fix A + fix B applied: this test still fails the same way (no behaviour change);
# the same test with the reader wrapped in AssertingDirectoryReader, expecting AssertionError from freq(), passes- The javadoc lists what is illegal after
advanceShallow(target); reading the current document is not on the list, andfreq()promises the "term frequency in the current document". - Users are sent here: the package overview shows
advanceShallow, and its author pointed a java-user reader to this javadoc. AssertingScoreralready rejects the Scorer-level form;AssertingImpactsEnumletsfreq()through.
The full argument284 words
- The javadoc enumerates the forbidden calls (
ImpactsSource.java:33-37): "After this method has been called,nextDoc()may not be called if the current doc ID is less thantarget - 1andadvance(int)may not be called on targets that are less thantarget." - The inherited contracts point the other way.
PostingsEnum#freq(PostingsEnum.java:74-77): "Returns term frequency in the current document ... Do not call this beforenextDocis first called, nor afternextDocreturnsNO_MORE_DOCS."DocIdSetIterator#advancefortarget > current"behaves as if written" as anextDoc()loop (DocIdSetIterator.java:144-153), soadvance(256)must return 256.docIDRunEnd(): "All docs in range[docID(), docIDRunEnd())match this iterator" (:251).Scorer#advanceShallow: "Calling this method doesn't modify the currentdocID()" (Scorer.java:66). - Users are sent here. The
indexpackage overview showsimpactsEnum.advanceShallow(targetDocID)(index/package-info.java:228-248). On java-user ("How to access block-max metadata?", 2020-10-12) Adrien Grand pointed a user toTermsEnum#impactsand the "documentation for ImpactsSource#advanceShallow and ImpactsSource#getImpacts", and wrote that "advanceShallow only advances the cursor for block-max metadata, this allows reasoning about maximum scores without actually advancing the doc ID". - Not self-evident to committers. In the #14390 review (2025-03-22) gf2121 asked whether
docIDRunEndshould be tested against "advance,intoBitset,advanceShallow", and jpountz answered that "'sane' impls of docIDRun should be free of side effects". - The rule exists only in code.
AssertingScorer(LUCENE-8135, 2018) entersSHALLOW_ADVANCINGafteradvanceShallow(target != doc)and rejectsscore()(AssertingScorer.java:110,:127);nextPostingsassertsneedsRefilling == false(#14679); #14185 removed a special-case eager refill fromadvanceShallow(fixing its interaction with #14164), so the refill is always deferred since. No javadoc, package doc,CHANGES.txtorMIGRATE.mdtext states the precondition.
The devil's-advocate check140 words
Strongest case for "works as designed" (the study's intended-behaviour checker and the cross-model refute review): ImpactsSource is @lucene.internal and ImpactsEnum @lucene.experimental; advanceShallow is the skip step of advance(); every checked in-tree caller follows "peek, then move"; AssertingScorer already encodes the rule; and the behaviour has been stable since 2018 with no complaint.
Why it fails. It shows the codec is right, which this page concedes; it does not make the javadoc complete. The javadoc lists the forbidden follow-ups explicitly, so a reader takes the list as complete; ImpactsEnum comes from the public TermsEnum#impacts, is shown in the package overview and was recommended to users by its author; the reviewer itself called the javadoc "genuinely misleading". The fix is one sentence and an assertion, with no behaviour change: a small hardening that upstream may still decline as "internal".
Recommended A. State the precondition in ImpactsSource#advanceShallow (recommended)
No codec change. One javadoc sentence in ImpactsSource#advanceShallow: after advanceShallow(target) with target > docID(), do not read the current document until nextDoc() or advance(). Plus the matching assertion in AssertingImpactsEnum. Validated with gradle: format and javadoc checks pass, the wrapped test passes, and 116 tests of twelve selected suites pass with the Asserting codec forced.
* - 1} and {@link DocIdSetIterator#advance(int)} may not be called on targets that are less than
- * {@code target}.
+ * {@code target}. If {@code target} is greater than the current doc ID, methods that read data of
+ * the current document, such as {@link PostingsEnum#freq()}, {@link PostingsEnum#nextPosition()}
+ * or {@link DocIdSetIterator#docIDRunEnd()}, may not be called until the iterator has been moved
+ * with {@link DocIdSetIterator#nextDoc()} or {@link DocIdSetIterator#advance(int)}: the current
+ * doc ID does not change, but implementations may have already moved their internal state past
+ * the current document.All options and trade-offs4 options ยท 700 words
Javadoc plus test framework; the codec's deferred refill is intended and should not change. Options A and B were applied together in a scratch worktree of main 645d4fe14a20 and checked with gradle.
One sentence after the existing restrictions: per-document reads are not allowed after a shallow advance past docID() until the iterator is moved.
* - 1} and {@link DocIdSetIterator#advance(int)} may not be called on targets that are less than
- * {@code target}.
+ * {@code target}. If {@code target} is greater than the current doc ID, methods that read data of
+ * the current document, such as {@link PostingsEnum#freq()}, {@link PostingsEnum#nextPosition()}
+ * or {@link DocIdSetIterator#docIDRunEnd()}, may not be called until the iterator has been moved
+ * with {@link DocIdSetIterator#nextDoc()} or {@link DocIdSetIterator#advance(int)}: the current
+ * doc ID does not change, but implementations may have already moved their internal state past
+ * the current document.Trade-offs
- Implementation-neutral (no mention of blocks); it mirrors the rule
AssertingScoreralready enforces (target != doc). - It is stricter than the default codec needs (a target inside the current block moves no state there,
Lucene104PostingsReader.java:879), which keeps the rule simple and future-proof. The methods are examples ("such as"); it does not say whether the bulkintoBitSet()andnextPostings()are covered, and B does not check them either. Scorer#advanceShallowhas the same gap forscore()(option C).
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20::lucene:core:checkGoogleJavaFormatand:lucene:core:renderJavadocpass (together with B, in the second pass; the first stopped at the format check on the copied repro's 101-column comment), and the renderedImpactsSource.htmlshows the new sentence. - The minimal test was re-run with the fix and still fails (
expected:<256> but was:<261>), as it must: the change is in a comment. - Not run:
gradlew checkor the whole core suite.
Remember whether advanceShallow went past the current doc; assert on freq(), nextPosition(), startOffset(), endOffset() and getPayload(); clear the flag on nextDoc()/advance(). +19 lines in AssertingLeafReader.java (one blank), none removed.
// true after advanceShallow() to a target beyond the current doc, until nextDoc() or advance()
private boolean shallowAdvancedPastDoc;
// advanceShallow(target), after the existing asserts:
shallowAdvancedPastDoc |= target > docID();
// first line of freq(), nextPosition(), startOffset(), endOffset(), getPayload():
assert shallowAdvancedPastDoc == false : shallowAdvancedPastDocMessage("freq");
// nextDoc() and advance(), before delegating:
shallowAdvancedPastDoc = false;Trade-offs
- Catches the misuse at the call, before any data is read, so the eager
freq()inAssertingPostingsEnumno longer hides anything and can stay. - It does not cover
docIDRunEnd(),intoBitSet()ornextPostings()of the codec enum:AssertingImpactsEnumdoes not delegate those today (the wrapper runs the safe defaults).
What the checks validated
- Gradle, same worktree, A and B applied:
:lucene:test-framework:checkGoogleJavaFormatand:lucene:core:checkGoogleJavaFormatpass. - The wrapped form of the minimal test (reader in
AssertingDirectoryReader,expectThrows(AssertionError.class, impacts::freq), thenadvance(256)= 256) fails on stock main ("Expected exception AssertionError but no exception was thrown") and passes with B (assertions on). - None of twelve selected suites tripped the new assertion in the configurations run:
TestTermScorer,TestWANDScorer,TestMaxScoreBulkScorer,TestBlockMaxConjunction,TestBlockMaxConjunctionBulkScorer,TestReqOptSumScorer,TestSynonymQuery,TestPhraseQuery,TestDisjunctionScoreBlockBoundaryPropagator,TestBoolean2,TestFeatureFieldandTestCheckIndexpass with-Ptests.codec=Asserting(the Asserting codec's postings come wrapped inAssertingImpactsEnum), 116 tests with 3 skipped (the log does not name them), seedsDEADBEEFand5EED1234;TestAssertingLeafReader(test-framework) passes. Not a whole-tree result:TestBoolean2builds some indexes withTestUtil.getDefaultCodec()(TestBoolean2.java:102,148,174,186), bypassing the forced codec. - Not run: the whole core and module suites, nightly tests, other codecs forced.
Scorer.java:62-70 has the same gap for score(); AssertingScorer already enforces it, so only the javadoc is missing there.
Decode pending freqs before shallow-advancing and defer the position seek and the docCountLeft update to the refill. It would add work to a call the javadoc describes as "cheaper than calling advance" (ImpactsSource.java:30), so it would need a performance evaluation, and every checked in-tree caller already follows the protocol. Not built.
Downstream code with custom block-max scoring over TermsEnum#impacts or Scorer#advanceShallow should move the iterator before reading the current document again.
Non-formal. A hunt unit read the Lucene104 postings reader's state machine while modelling it for a postings differential test and saw that a shallow advance moves the streams while the document stays; that test itself had excluded this call order. A JUnit test on the stock jars is the witness.
The method in full124 words
- Lead. Code reading of
Lucene104PostingsReader.BlockPostingsEnumin a hunt unit (postings), while modelling it for the unit's differential test (191M checks, 0 mismatches). That test excluded per-document reads after a pending shallow advance by design, so no tool output gave the first signal. - Witness. A public-API JUnit test on the stock jars, later reduced to the one-method test shown here (1,200 documents, deterministic).
- Verification. An independent reproducer, cross-model reviews (OpenAI GPT-5.6: an executed intended-lens review, a reading-only refute review in the study and an executed refute review of the reduced test), an intended-behaviour devil's advocate, an adjudication, version-history runs (8.0.0 to main, plus the introducing commit built from source), a security probe, a re-verification and tie-break on main
645d4fe14a20, and this brief's gradle runs.
No prior report found (2026-10-26, and again from scratch 2026-11-01: 102 scripted queries over GitHub, the JIRA archive, the mailing lists, the web and downstream code, 46 manual GitHub searches, all 409 open PRs). Closest: a 2025 review question on #14390.
Searches4 searches
- Closest touch: the #14390 review exchange (2025-03-22), a test-coverage question about
docIDRunEndafteradvanceShallow; no wrong value or missing precondition was described. - Open PR #14716 "Merge PostingsEnum and ImpactsEnum" (jpountz, stale) puts
advanceShallowon everyPostingsEnumand keeps only the twolastShallowTargetchecks. - Read, not the same bug: #5270 (LUCENE-4198), #9190 (LUCENE-8142), #9359 (LUCENE-8312), #9183 (LUCENE-8135,
SHALLOW_ADVANCING), #14164/#14185 (lazy refill, a different bug in a legal sequence), #14679, #13585, #16855; #16144 is a precedent (a positioning precondition documented in javadoc and asserted inAssertingLeafCollector); java-user threads of 2020-10 and 2026-02 point users toadvanceShallowwith no warning. - Gaps: GitHub issue search does not index PR review comments; JIRA comments were read through the migrated issues; a private report to security@ would not be visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, incl. the migrated JIRA archive) | 2026-10-26, 2026-11-01 | a scripted prior-report search (45 issue/PR and 4 commit queries, 6 JIRA-archive queries) and 46 GraphQL searches on class, method and field names and symptom phrases | no report; closest #14390 review comment |
| Open PRs | 2026-11-01 | the changed files of all 409 open apache/lucene PRs | #14716 touches the API (stale); #16248 and the AssertingLeafReader PRs have no impacts lines |
| Mailing lists, web and downstream projects | 2026-10-26, 2026-11-01 | dev@, java-user@, issues@, commits@ (36 queries); 7 web queries; Elasticsearch, OpenSearch, Solr (4 queries plus manual GraphQL) | no report; java-user threads point users to advanceShallow without a warning |
| Upstream main | 2026-11-01 | ImpactsSource.java and AssertingLeafReader.java on upstream main, branch_11x, branch_10_6 | same ImpactsSource.java blob as 645d4fe14a20; AssertingImpactsEnum unchanged; not fixed |
Gap since 8.0.0: LUCENE-4198 (f410df81136, 2018-01-31) added advanceShallow with this behaviour, and LUCENE-8142 (2018-05-02) wrote today's javadoc. Every release from 8.0.0 to 10.6.0 and main is affected; which read goes wrong changed with the postings format, and this repro fails from 9.12.0.
Versions tested7 rows
- Introduced:
f410df81136(LUCENE-4198 "Give codecs the opportunity to index impacts", Adrien Grand, 2018-01-31; JIRA patch era, no PR). lucene-core built at the commit already returns positions[6]for doc 0 and[2, 8]for doc 3 after a far shallow advance; the parentebb346d5820has noImpactsEnum. - Contract text:
af680af77f3(LUCENE-8142, #9190, 2018-05-02), moved unchanged toImpactsSourceby0a1de2c4a57(LUCENE-8312, #9359); only reformatted since. Same text in 8.0.0, 10.6.0 and main. - Symptoms by release (release boundaries executed; commits in between not bisected): positions from 8.0.0; a wrong
freq()value 8.2.0 to 9.11.1 (5974586b87f, LUCENE-8901, lazy freqs); correctfreq()but a wrong nextadvance()from 9.12.0 (#13585, skip data inlined); wrongdocIDRunEnd()from 10.2.0 (#14390). - Upstream:
ImpactsSource.javahas the same blob on upstream main (2026-11-01),branch_11xandbranch_10_6;AssertingImpactsEnumis unchanged.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails (261), gradle, assertions on and off; study tests 4 of 9 fail |
| 10.5.2, 10.6.0 | affected | minimal test fails (261) |
| 10.1.0, 10.2.0 | affected | minimal test fails (258); docIDRunEnd() wrong from 10.2.0 |
| 9.12.0, 9.12.3, 10.0.0 | repro from here | minimal test fails (2897, beyond maxDoc 1200) |
| 8.2.0 to 9.11.1 | other symptom | freq() of the current doc (3 on 8.2.0 to 9.8.0, 0 on 9.9.0 to 9.11.1; right: 1) and its positions are wrong (probe); the minimal test passes on 9.11.1 |
| 8.0.0, 8.1.1 | first release | positions of the current doc wrong ([6] instead of [0], probe) |
| 7.7.3 | not affected | no ImpactsEnum (API absent) |
All checks agree on the facts and on the scope (documentation and assertion gap; codec intended): the finder, an independent reproducer (4 of 9 tests fail, controls pass), a devil's advocate, the adjudication (confirmed, doc) and a re-verification on current main. Two cross-model refute reviews voted not real against a code-bug reading; the executed one called the javadoc "genuinely misleading".
Checks and verdicts293 words
The study ran on main ea57360912ed; every file involved has the same blob at 645d4fe14a20.
| Check | Verdict | What it found |
|---|---|---|
| finder (a hunt unit) | reproduced, doc | Public-API JUnit fails 3 of 3 on main (assertions on and off); its two current-document tests fail on 9.11.1, 9.12.3, 10.0.0, 10.1.0 and 10.5.2, its docIDRunEnd test on 10.2.0, 10.3.2 and 10.5.2 (assertions on). |
| cross-model review, intended lens | real (high) | OpenAI GPT-5.6, executed: its own test fails 3 of 5 on main and on 10.5.2, controls pass; at least a contract/documentation defect. |
| independent reproducer | real, doc | Deterministic 4,000-document JUnits: wrong positions, docIDRunEnd() 3328, advance(3000) = 3005; 4 of 9 fail, controls pass. |
| cross-model review, refute lens (study) | not real (high), against a code-bug reading | OpenAI GPT-5.6, reading only (it could not run code): ImpactsSource is @lucene.internal; agreed on the root cause and allowed that a documentation/assertion defect remains. A separate re-run of the finder's test failed 3 of 3 on main. |
| cross-model review, refute lens (2026-11-01) | not real (high), against a code-bug reading | OpenAI GPT-5.6, executed: its own test throws EOFException; treats @lucene.internal as out of contract; calls the javadoc "genuinely misleading". |
| devil's advocate | real, doc only | Codec intended; AssertingScorer rejects the Scorer form, AssertingImpactsEnum lets it through. |
| adjudication | confirmed, doc, medium | Re-ran the independent reproduction's tests with a new seed: 4 of 9 fail. |
| re-verification and tie-break (2026-11-01) | real, doc and assertion gap | Minimal test fails on main and 10.6.0 with new seeds; the independent reproduction's tests fail on 645d4fe14a20; a probe shows the wrapper hiding it and the TermScorer form (261). |
| this brief (2026-11-01) | reproduced; fixes validated | Gradle in a scratch worktree of 645d4fe14a20. |
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 443 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,388 words
- Registry title
- ImpactsSource#advanceShallow javadoc does not say that the current document's freq(), positions and docIDRunEnd() become invalid after a shallow advance past its block: the default codec then returns another block's data, and a lazy freq() breaks the next legal advance() (javadoc and assertion gap; the codec behaviour is intended)
- Area
- index / ImpactsEnum shallow-advance contract (ImpactsSource javadoc, test-framework AssertingImpactsEnum); codecs / Lucene104 postings reader
- Priority
- Low, rank 1272: The ImpactsSource#advanceShallow javadoc omits that the current document's data becomes invalid after a shallow advance into a later block, while the codec behaviour is intended.
- Impact class
- minor
- Impact note
- Documentation and assertion gap only: it misleads code that uses the @lucene.experimental ImpactsEnum directly (public TermsEnum#impacts) and reads the current document's data, or bulk-reads, after advanceShallow(target) into a later block without advancing first; with the default codec such code gets wrong positions, freq() or docIDRunEnd(), a following legal advance() that returns a document without the term (in variants a doc id beyond maxDoc or an EOFException), and, in the security probe, an intoBitSet() that did not return and a nextPostings() that skipped matches; no in-tree query, scorer, collector or CheckIndex path uses this call order, so stock searches are not affected; default codec, no scale needed.
- Summary
- You take an ImpactsEnum from TermsEnum#impacts, stand on a document, call advanceShallow(target) with a target in a later 256-doc block to peek at upcoming impacts, and then read the current document's freq(), positions or docIDRunEnd(); the javadoc forbids only nextDoc() and too-small advance() targets afterwards and says docID() does not change, so you expect the current document's own data and a working advance(target). Instead the default codec has already moved its streams to the target block on purpose: on main the positions of doc 0 read as [5] instead of [0], docIDRunEnd() claims docs 0 to 3327 all match although only every third does, and after freq() the legal advance(3000) returns doc 3005, which does not contain the term. Lucene's own callers always advance before reading, so the codec behaviour is intended; what is missing is this precondition in the ImpactsSource javadoc and a check for it in the test framework's AssertingImpactsEnum.
- Introduced
- f410df81136 (LUCENE-4198 'Give codecs the opportunity to index impacts', migrated as apache/lucene#5270, by jpountz; JIRA patch era, no PR): it added ImpactsEnum#advanceShallow, and its first impacts enum (Lucene50PostingsReader.BlockImpactsEverythingEnum) already repositioned the positions and doc streams on a shallow advance while docID() stayed; the contract text in force today is af680af77f3 (LUCENE-8142, #9190, 2018-05-02), moved unchanged to ImpactsSource by 0a1de2c4a57 (LUCENE-8312, 2018-05-25), and it never named the per-document methods [read: git log --diff-filter=A, git show; exec: release level, 7.7.3 has no ImpactsEnum, 8.0.0 fails]. Later commits changed which reads go wrong: 5974586b87f (LUCENE-8901, apache/lucene-solr#595) wrong freq() values (8.2.0-9.11.1); #13585 'Inline skip data into postings lists' (b4a8810b7ae, backport 59b7c28eb47) freq() correct but breaking the next advance() (9.12.0 on); #14390 (db00348ef18, backport 6bef45756a9) docIDRunEnd() on postings (10.2.0 on) [exec: release boundaries 8.1.1/8.2.0, 9.11.1/9.12.0, 10.1.0/10.2.0; read: blame; intermediate commits not bisected] (2018-01-31 (f410df81136); contract text 2018-05-02 (af680af77f3); symptom changes 2019-07-02 (LUCENE-8901), 2024-07-31 (#13585) and 2025-03-24 (#14390))
- First affected version
- 8.0.0 [exec: 7.7.3 does not compile against the API, 8.0.0 fails]; by symptom: wrong positions from 8.0.0, wrong freq() value 8.2.0-9.11.1, freq() then advance() corruption from 9.12.0, wrong docIDRunEnd() from 10.2.0 (the method first appeared there) [exec]
- Affected versions
- every release from 8.0.0 to 10.6.0 and main ea57360912ed (10.6.0 [exec 2026-11-01]: the minimal test fails on the release jars with -ea and -da, advance(256) = 261). [exec] the follow-up's history probes and the independent reproduction's JUnit fail on 8.0.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.8.0, 9.9.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.1.0, 10.2.0, 10.3.2, 10.4.0, 10.5.2 and main (identical values with -ea and -da wherever both ran: 8.0.0, 8.11.4, 9.11.1, 9.12.3, 10.5.2, main); the finder's repro also fails on 9.11.1, 9.12.3, 10.0.0, 10.1.0 and 10.5.2, and its docIDRunEnd test on 10.2.0, 10.3.2 and 10.5.2; [read] the other 8.x-10.x releases (same javadoc text, same deferred-refill design). Not affected: 7.7.3 and earlier (no ImpactsEnum; 7.3.0-7.7.3 by git ls-tree, 7.7.3 by execution). Upstream main and branch_10x (1309534296e3) still carry the javadoc unchanged [read 2026-10-26]. Not executed: offsets and payloads after a far shallow advance, x86, JDKs other than 25
- 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
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26: two scripted prior-report searches (9 GitHub issue/PR, 32 mailing-list queries over dev@, java-user@, issues@ and commits@, 6 JIRA-archive and 6 web queries), 69 manual GitHub GraphQL issue/PR searches, the full git history, upstream commit listings per file, downstream searches in Elasticsearch, OpenSearch and Solr and a scan of open PRs: 172 query lines, 26 hits read, none the same bug; also the finder's scripted prior-report search (23 queries) and the intended-behaviour check's 20 mailing-list queries). Closest touch: in the #14390 review (2025-03-22) gf2121 asked whether docIDRunEnd should also be tested after advance, intoBitSet and advanceShallow, and jpountz judged it unnecessary because 'sane' docIDRunEnd implementations are free of side effects; a test-coverage question, no defect described. Not fixed upstream: the ImpactsSource#advanceShallow javadoc is unchanged on main ea57360912ed and branch_10x 1309534296e3, and the only open PR touching the API, #14716 ('Merge PostingsEnum and ImpactsEnum', by jpountz, stale), keeps only the nextDoc/advance checks. Related, not this bug: LUCENE-4198 (#5270) and LUCENE-8142 (#9190), the origin of the API and of today's contract text; LUCENE-8135 (#9183, AssertingScorer SHALLOW_ADVANCING); LUCENE-8314 (#9361); #14164 and #14185 (lazy refill after a shallow advance, a different bug in a legal sequence); #14679 ('assert needsRefilling == false' in nextPostings); #12668, #12815, #14857; and the java-user thread 'How to access block-max metadata?' (2020-10-12), where jpountz pointed an external user to TermsEnum#impacts plus advanceShallow/getImpacts without this warning
- Lucene commit tested
- main
645d4fe14a20(2026-10-28), through gradle in a scratch worktree (2026-11-01): the minimal test fails on stock main with assertions on and off (261); with the javadoc fix (A) and the AssertingImpactsEnum check (B) applied,checkGoogleJavaFormatof core and test-framework and core'srenderJavadocpass and the renderedImpactsSource.htmlshows the new sentence; the minimal test still fails (the codec is unchanged); the same test with the reader wrapped inAssertingDirectoryReader, expecting anAssertionErrorfromfreq(), fails on stock main (no assertion fires) and passes with B; 12 selected suites (116 tests, 3 skipped) pass with A+B and-Ptests.codec=Asserting, seedsDEADBEEFand5EED1234;TestAssertingLeafReaderpasses. Standalone: the same test on the 10.6.0 and 10.5.2 release jars with assertions on and off (2026-11-01), and the study's 4,000-document tests (positions,docIDRunEnd(), freq-then-advance) on main645d4fe14a20(4 of 9 fail, all controls pass).ImpactsSource.javahas the same blob on upstream main (2026-11-01).
- Finding record
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/ImpactsSource.java:25 (@lucene.internal), :29-39 (advanceShallow javadoc: restricts only advanceShallow below docID(), nextDoc() when docID() < target - 1, advance() below target); ImpactsEnum.java:22 (@lucene.experimental); TermsEnum.java:177 (public impacts(int))a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:406-407 (needsRefilling 'shallow-advanced to a new block that we have not decoded yet'), :581-588 (lazy freq() seeks docIn to freqFP), :879-894 (advanceShallow, doAdvanceShallow), :978 (intoBitSet refills only when doc == level0LastDocID), :1035-1036 (nextPostings 'assert needsRefilling == false'), :1099-1129 (docIDRunEnd from the level bounds)a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/Scorer.java:62-71 ('Calling this method doesn't modify the current docID()'); index/PostingsEnum.java:74-88 (freq 'in the current document', nextPosition), :132 (nextPostings @lucene.internal); search/DocIdSetIterator.java:203 (intoBitSet @lucene.internal), :245-263 (docIDRunEnd: 'All docs in range [docID(), docIDRunEnd()) match this iterator'); search/BooleanWeight.java:58-59 (non-scoring clauses get COMPLETE_NO_SCORES)a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:99-131 (SHALLOW_ADVANCING at :110, score() asserts iterating()); tests/index/AssertingLeafReader.java:635-719 (AssertingImpactsEnum checks only lastShallowTarget for nextDoc/advance, delegates freq()/nextPosition())
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:406-407, 581-596, 626-631, 818-895, 924-931, 1036, 1099-1129 (at 645d4fe14a20)lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:502, 521, 635-719 (at 645d4fe14a20)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/TermsEnum.java:172-177; lucene/core/src/java/org/apache/lucene/search/Scorer.java:62-70 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/ImpactsSource.java:25, 29-39; lucene/core/src/java/org/apache/lucene/index/ImpactsEnum.java:22; lucene/core/src/java/org/apache/lucene/index/PostingsEnum.java:74-88; lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:144-153, 245-260; lucene/core/src/java/org/apache/lucene/search/Scorer.java:62-70; lucene/core/src/java/org/apache/lucene/index/package-info.java:228-248 (at 645d4fe14a20)lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:99-133 (at 645d4fe14a20)
- TestAdvanceShallowThenFreqThenAdvance.java:
repro/LFV-156/src/org/apache/lucene/index/TestAdvanceShallowThenFreqThenAdvance.java(52 lines)
assets/LFV-156/bug.png(443 KB)
