All findings · Lucene finding · LFV-156 · impactsenum-advanceshallow-per-doc-precondition-undocumented

ImpactsSource#advanceShallow javadoc omits that the current document must not be read after a shallow advance past it

After advanceShallow(target) into a later block, the current document's positions and docIDRunEnd() come from another block, and reading its freq() makes the next legal advance(256) return 261. The codec behaviour is intended; the javadoc never forbids these reads and the test framework never checks them.

Summary

Priority
Low, rank 1272The 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
minor
Since
8.0.0 (LUCENE-4198, 2018-01-31); this repro from 9.12.0introduced 2018-01-31 · about 8 years
Affected
8.0.0 to 10.6.0 and main; documentation and test-framework only
Status
documented
Found by
non-formal: code reading of the Lucene104 postings reader while modelling it; the witness is a JUnit test

AI-assisted page: this finding was first signalled by code reading or testing 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

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.

Background: block-max scoring and advanceShallow

  1. 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.
  2. ImpactsEnum.advanceShallow(target) moves only to the block that contains target, so that getImpacts() describes it. The iterator's docID() does not change.
  3. 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.
Diagram of the bug: the postings of term t in 256-document blocks. An ImpactsEnum stands on doc 0. advanceShallow(256) moves the reader's file position to block 1's data while docID() stays 0. freq() of doc 0 seeks back to block 0's freqs and returns 1. advance(256) then refills from the wrong position and returns 261 instead of 256; without the freq() call it returns 256.
Block 0 and block 1 of the postings of 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.

The finding, step by step

  1. You index 1,200 documents that all contain t, take an ImpactsEnum from TermsEnum#impacts, and call nextDoc(): you are on doc 0. The postings are stored in blocks of 256 documents.
  2. You call advanceShallow(256) to peek at the next block's score bounds; docID() stays 0. The javadoc now forbids only nextDoc() jumps and advance() targets below 256.
  3. You read freq() of doc 0 (it returns 1, correct) and call advance(256). Every document has t, so you expect 256.
  4. 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 the freq() call: 256.
  5. In two 4,000-document variants, other reads of the current document go wrong too: with t in every third document, a position of 5 instead of 0; with t in docs 0-255 and then in every third document, a docIDRunEnd() of 3328 although doc 256 does not match. Lucene's own queries never read in this order.

Who is affected

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.

The mechanism (main 645d4fe14a20, Lucene104PostingsReader.BlockPostingsEnum)

  1. 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). After nextDoc() 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).
  2. The shallow advance moves the streams, not the document. advanceShallow(256) sees target > level0LastDocID (255) and runs doAdvanceShallow / skipLevel0To, which reads block 1's skip data from the doc stream (and, with positions, seeks the position stream), then sets needsRefilling = true (:879-895, :818-876). doc and 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).
  3. 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.
  4. The next legal advance() decodes the wrong bytes. advance(256) refills because needsRefilling is 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.
  5. 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 (UNARY encoding), otherwise the default one-doc run is returned, which is correct. The study's security probe also saw intoBitSet() not return within 20 s (5 of 6 cases) and nextPostings() skip matches with assertions off; nextPostings starts with assert needsRefilling == false (:1036).

Why tests never see it

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.

The numbers

  • Minimal repro (1,200 docs, all with t, freq 1 to 5): advance(256) after freq() returns 261; without freq(), 256.
  • Study tests (two 4,000-document indexes, advanceShallow(3000)): with t in every third document, positions of doc 0 [5] instead of [0], of doc 3 [2, ...] instead of [1, 4], and freq() then advance(3000) = 3005, a document without t; 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 beyond maxDoc (2897 with 1,200 docs on 9.12.x).
  • One level up, a TermScorer in TOP_SCORES mode: advanceShallow(256), score(), then advance(256) also returns 261 (AssertingScorer rejects that score() with SHALLOW_ADVANCING).
Exposure and likelihood240 words
  • Who. Code that uses ImpactsEnum directly (from the public TermsEnum#impacts, TermsEnum.java:172-177) and reads freq(), positions, docIDRunEnd() or bulk postings of the current document after advanceShallow(target) into a later block. The same order through Scorer#advanceShallow and score() on a TermScorer gives 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 beyond maxDoc. The same with assertions on and off.
  • Who is not hit. TermQuery, PhraseQuery, SynonymQuery, FeatureQuery, WAND, MaxScore, block-max conjunctions, ReqOptSumScorer and CheckIndex move the iterator after a shallow advance past the current document before reading (CheckIndex reads after advanceShallow(docID()), which is harmless); non-scoring clauses get plain postings, at most wrapped in a SlowImpactsEnum whose advanceShallow does 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.experimental API 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.

Minimal repro

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.

TestAdvanceShallowThenFreqThenAdvance.java52 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 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));
      }
    }
  }
}
Command
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 contains t, and the javadoc allows advance(256) after advanceShallow(256).
Observed
advance(256) returns 261 (expected:<256> but was:<261>), with assertions on and off; without the freq() call it returns 256.

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

More files, environment, full output354 words
  • Deterministic input. ByteBuffersDirectory, IndexWriterConfig() with the default StandardAnalyzer, the default codec pinned, no random() in the test (the test framework still randomizes locale, time zone and the like); seeds DEADBEEF, 5EED1234 and 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 AssertingDirectoryReader and the last two lines replaced by expectThrows(AssertionError.class, impacts::freq); assertEquals(256, impacts.advance(256)); (method testFreqAfterAdvanceShallowIsRejected). 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).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on the 10.6.0 and 10.5.2 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
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)

Observed output

# 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

Why it looks unintended

  • The javadoc lists what is illegal after advanceShallow(target); reading the current document is not on the list, and freq() 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.
  • AssertingScorer already rejects the Scorer-level form; AssertingImpactsEnum lets freq() 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 than target - 1 and advance(int) may not be called on targets that are less than target."
  • The inherited contracts point the other way. PostingsEnum#freq (PostingsEnum.java:74-77): "Returns term frequency in the current document ... Do not call this before nextDoc is first called, nor after nextDoc returns NO_MORE_DOCS." DocIdSetIterator#advance for target > current "behaves as if written" as a nextDoc() loop (DocIdSetIterator.java:144-153), so advance(256) must return 256. docIDRunEnd(): "All docs in range [docID(), docIDRunEnd()) match this iterator" (:251). Scorer#advanceShallow: "Calling this method doesn't modify the current docID()" (Scorer.java:66).
  • Users are sent here. The index package overview shows impactsEnum.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 to TermsEnum#impacts and 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 docIDRunEnd should 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) enters SHALLOW_ADVANCING after advanceShallow(target != doc) and rejects score() (AssertingScorer.java:110, :127); nextPostings asserts needsRefilling == false (#14679); #14185 removed a special-case eager refill from advanceShallow (fixing its interaction with #14164), so the refill is always deferred since. No javadoc, package doc, CHANGES.txt or MIGRATE.md text 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".

Suggested fix

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.

A. State the precondition in ImpactsSource#advanceShallow (recommended) (validated by the checks)

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 AssertingScorer already 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 bulk intoBitSet() and nextPostings() are covered, and B does not check them either.
  • Scorer#advanceShallow has the same gap for score() (option C).

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20: :lucene:core:checkGoogleJavaFormat and :lucene:core:renderJavadoc pass (together with B, in the second pass; the first stopped at the format check on the copied repro's 101-column comment), and the rendered ImpactsSource.html shows 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 check or the whole core suite.

B. Assert it in AssertingImpactsEnum (recommended together with A) (validated by the checks)

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() in AssertingPostingsEnum no longer hides anything and can stay.
  • It does not cover docIDRunEnd(), intoBitSet() or nextPostings() of the codec enum: AssertingImpactsEnum does not delegate those today (the wrapper runs the safe defaults).

What the checks validated

  • Gradle, same worktree, A and B applied: :lucene:test-framework:checkGoogleJavaFormat and :lucene:core:checkGoogleJavaFormat pass.
  • The wrapped form of the minimal test (reader in AssertingDirectoryReader, expectThrows(AssertionError.class, impacts::freq), then advance(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, TestFeatureField and TestCheckIndex pass with -Ptests.codec=Asserting (the Asserting codec's postings come wrapped in AssertingImpactsEnum), 116 tests with 3 skipped (the log does not name them), seeds DEADBEEF and 5EED1234; TestAssertingLeafReader (test-framework) passes. Not a whole-tree result: TestBoolean2 builds some indexes with TestUtil.getDefaultCodec() (TestBoolean2.java:102,148,174,186), bypassing the forced codec.
  • Not run: the whole core and module suites, nightly tests, other codecs forced.

C. The same sentence on Scorer#advanceShallow (optional, not built)

Scorer.java:62-70 has the same gap for score(); AssertingScorer already enforces it, so only the javadoc is missing there.

Not recommended: keep the current document valid in the codec

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 note

Downstream code with custom block-max scoring over TermsEnum#impacts or Scorer#advanceShallow should move the iterator before reading the current document again.

How it was found

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.BlockPostingsEnum in 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.

Prior reports, history and checks

Prior reports

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 docIDRunEnd after advanceShallow; no wrong value or missing precondition was described.
  • Open PR #14716 "Merge PostingsEnum and ImpactsEnum" (jpountz, stale) puts advanceShallow on every PostingsEnum and keeps only the two lastShallowTarget checks.
  • 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 in AssertingLeafCollector); java-user threads of 2020-10 and 2026-02 point users to advanceShallow with 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.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, incl. the migrated JIRA archive)2026-10-26, 2026-11-01a 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 phrasesno report; closest #14390 review comment
Open PRs2026-11-01the 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 projects2026-10-26, 2026-11-01dev@, 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 main2026-11-01ImpactsSource.java and AssertingLeafReader.java on upstream main, branch_11x, branch_10_6same ImpactsSource.java blob as 645d4fe14a20; AssertingImpactsEnum unchanged; not fixed

History

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 parent ebb346d5820 has no ImpactsEnum.
  • Contract text: af680af77f3 (LUCENE-8142, #9190, 2018-05-02), moved unchanged to ImpactsSource by 0a1de2c4a57 (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); correct freq() but a wrong next advance() from 9.12.0 (#13585, skip data inlined); wrong docIDRunEnd() from 10.2.0 (#14390).
  • Upstream: ImpactsSource.java has the same blob on upstream main (2026-11-01), branch_11x and branch_10_6; AssertingImpactsEnum is unchanged.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails (261), gradle, assertions on and off; study tests 4 of 9 fail
10.5.2, 10.6.0 affectedminimal test fails (261)
10.1.0, 10.2.0 affectedminimal test fails (258); docIDRunEnd() wrong from 10.2.0
9.12.0, 9.12.3, 10.0.0 repro from hereminimal test fails (2897, beyond maxDoc 1200)
8.2.0 to 9.11.1 other symptomfreq() 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 releasepositions of the current doc wrong ([6] instead of [0], probe)
7.7.3 not affectedno ImpactsEnum (API absent)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) reproduced, docPublic-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, docDeterministic 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 readingOpenAI 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 readingOpenAI 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 onlyCodec intended; AssertingScorer rejects the Scorer form, AssertingImpactsEnum lets it through.
adjudication confirmed, doc, mediumRe-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 gapMinimal 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 validatedGradle in a scratch worktree of 645d4fe14a20.

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

Finding record

LFV-156, snapshot of 2026-11-06

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, checkGoogleJavaFormat of core and test-framework and core's renderJavadoc pass and the rendered ImpactsSource.html shows the new sentence; the minimal test still fails (the codec is unchanged); the same test with the reader wrapped in AssertingDirectoryReader, expecting an AssertionError from freq(), 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, seeds DEADBEEF and 5EED1234; TestAssertingLeafReader passes. 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 main 645d4fe14a20 (4 of 9 fail, all controls pass). ImpactsSource.java has the same blob on upstream main (2026-11-01).

Lucene and upstream sources

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)

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

Repro files

About the diagrams

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.