All findings · Lucene finding · LFV-664 · asserting-docidstream-foreach-upto-ignores-bound

Test framework: the asserting DocIdStream wrapper ignores the upTo of forEach(upTo, consumer)

In Lucene tests, newSearcher wraps every DocIdStream a collector receives, and the wrapper's forEach(upTo, consumer) ignores upTo: forEach(5, ...) over docs 0-9 delivers all ten docs, not 0-4. Collectors that read a stream in steps get wrong steps or a false AssertionError: exhausted; production search never uses this wrapper.

Summary

Priority
Low, rank 1221This test-framework bug (AssertingIndexSearcher ignores DocIdStream.forEach's upTo bound) affects only collectors that read the stream in bounded steps and breaks no in-tree test.
Impact
minor
Since
10.2.0 (#14273, merged 2025-03-28)introduced 2025-03-28 · about 1 year
Affected
10.2.0 to 10.6.0 and main; tests only (test framework)
Status
documented
Found by
semi-formal: code reading while modelling, then bounded-exhaustive translation validation

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The wrapper's forEach(upTo, consumer) asserts the bound, then calls stream.forEach(consumer), the one-argument overload that means "everything that is left", and records lastUpTo = upTo as if it had been bounded. Its siblings count(upTo) and intoArray(upTo, ...) pass the bound on.

Background: DocIdStream and the asserting searcher

  1. A DocIdStream hands a collector a batch of matching doc ids at once (LeafCollector.collect(DocIdStream)). Each doc can be consumed only once.
  2. It can be read in steps: forEach(upTo, consumer) and count(upTo) take only the docs below upTo; forEach(consumer) and count() take everything that is left ("terminal").
  3. In tests, LuceneTestCase.newSearcher returns an AssertingIndexSearcher. It wraps each collector, and each stream that collector receives, in checking wrappers that catch API misuse: on the normal bulk path, three nested layers per stream.
Diagram of the bug: a DocIdStream holds docs 0 to 9. A collector calls forEach(5, ...), which should deliver docs 0 to 4 and leave docs 5 to 9 for its next call, as a plain IndexSearcher does. Through the test framework's AssertingDocIdStream wrapper, the call is forwarded without the bound, so all ten docs arrive in the first step and the next step finds the stream empty (or, with assertions on and nested wrappers, fails with AssertionError: exhausted).
One stream of docs 0-9: forEach(5, ...) should deliver 0-4 and leave 5-9 for the next call; through the asserting wrapper it delivers all ten and leaves none. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. In a Lucene test, your collector's collect(DocIdStream stream) reads the stream in two steps: stream.forEach(5, consumer) for the docs below 5, then stream.count() for the rest. You search with newSearcher(reader), which returns an AssertingIndexSearcher.
  2. The stream holds docs 0-9. The javadoc says forEach(upTo, ...) visits docs "up to the given upTo exclusive", so you expect docs 0-4 first and a count of 5 next. A plain IndexSearcher gives exactly that.
  3. Instead, the test framework's wrapper calls the wrapped stream's unbounded forEach(consumer): the first step receives all 10 docs, 0-9, and the stream is used up.
  4. With assertions off, the next step gets nothing. With assertions on, the next legal count() fails with AssertionError: exhausted. A collector that only loops forEach(upTo) gets the extra docs silently, even with assertions on.

Who is affected

Only tests, in Lucene or in projects that build on its test framework: a collector that reads a DocIdStream in bounded steps with forEach(upTo, ...) (experimental, since 10.2.0) under AssertingIndexSearcher, the default of newSearcher. Index size and assertion mode do not matter. Lucene's runtime search paths never create the wrapper, and in Lucene no test fails today.

Mechanism, terms and numbers704 words

DocIdStream hands a collector a batch of matching doc ids, and since 10.2.0 it can be read in steps: forEach(upTo, consumer) and count(upTo) consume only the docs below upTo, and the rest stays for a later call. In tests, LuceneTestCase.newSearcher returns an AssertingIndexSearcher, which hands each stream to the collector inside the test framework's AssertingLeafCollector.AssertingDocIdStream. That wrapper's forEach(int upTo, IOIntConsumer) checks the bound, then calls the wrapped stream's one-argument forEach(consumer), which means forEach(NO_MORE_DOCS, consumer), and records upTo as if the call had been bounded.

So forEach(5, ...) over docs 0-9 delivers all ten docs. Under AssertingIndexSearcher, which on its normal bulk path nests three wrapper layers, the next legal call then sees an empty stream with assertions off and fails with AssertionError: exhausted with them on. Only tests are affected; in Lucene's non-test code the only bounded caller, the sandbox HistogramCollector, absorbs the extra docs, so no in-tree test fails. The fix is to pass upTo on.

The code (main 645d4fe14a20, AssertingLeafCollector.java)

@Override
public void forEach(int upTo, IOIntConsumer consumer) throws IOException {        // :209
  assert lastUpTo < upTo : "upTo=" + upTo + " but previous upTo=" + lastUpTo;   // :210
  stream.forEach(                                                                // :211  one-argument overload:
      doc -> {                                                                   //       forEach(NO_MORE_DOCS, ...)
        assert doc > lastCollected : "Out of order : " + lastCollected + " " + doc;
        assert doc >= min : "Out of range: " + doc + " < " + min;
        assert doc < max : "Out of range: " + doc + " >= " + max;
        consumer.accept(doc);
        lastCollected = doc;
      });
  lastUpTo = upTo;                                                               // :219  recorded as bounded
  if (upTo == DocIdSetIterator.NO_MORE_DOCS) {
    assert stream.mayHaveRemaining() == false;
  }
}
  • The overload. DocIdStream.forEach(consumer) is forEach(DocIdSetIterator.NO_MORE_DOCS, consumer) (DocIdStream.java:37-39), so every remaining doc reaches the consumer and the wrapped stream is used up.
  • Where the wrapper comes from. newSearcher(reader) passes wrapWithAssertions = true and always builds an AssertingIndexSearcher (LuceneTestCaseParent.java:2551-2558, :2626-2630). It wraps the collector in an AssertingCollector (AssertingCollector.java:66), and its bulk scorer wraps it twice more (AssertingBulkScorer.java:64, :79), so on the normal bulk path a stream passes through three layers. Each AssertingLeafCollector.collect(DocIdStream) (:89-109) hands on new AssertingDocIdStream(stream) (:104), except in about 1% of calls (rarely(), :90), where it replays the docs one by one.
  • Why "exhausted" with assertions on. The outer layer's bounded call becomes a terminal forEach(consumer) on the next layer in, which passes it on to the innermost layer; each of the two sets its own lastUpTo = NO_MORE_DOCS (:204). The outer layer recorded only lastUpTo = 5, so it lets the collector's next count() through (:228), and the middle layer's assert lastUpTo != NO_MORE_DOCS : "exhausted" fires (:227; :195 for forEach()): in the trace, count:227 is called from count:228. With assertions off that call sees an empty stream.
  • Why a forEach-only loop stays silent. After the first bounded step, mayHaveRemaining() returns false, the inner layer's consistency check (:285-290) holds, and the loop ends normally, with the later docs already delivered in the wrong step.
  • The siblings are right. count(int upTo) calls stream.count(upTo) (:237); intoArray(int upTo, int[]) calls stream.intoArray(upTo, array) (:263) and asserts array[count - 1] < upTo (:268).

The numbers

  • One wrapper layer (the minimal repro): forEach(5, ...) over docs 0-9 receives [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] instead of [0, 1, 2, 3, 4], with assertions on and off.
  • AssertingIndexSearcher (10 docs, a two-term SHOULD disjunction collected with COMPLETE_NO_SCORES, forEach(5, ...) then count()): assertions on, AssertionError: exhausted at AssertingLeafCollector.java:227 under three collect(:104) frames; assertions off, 5 docs at or above upTo reach the first step. An independent test with 12 docs, forEach(4, ...) then forEach(...), saw [0..11] in the first step and [] in the second (assertions off). A plain IndexSearcher gives the right split.
  • forEach-only loop (8-doc buckets, as histogram collectors do): the bucket below 8 received docs 8, 9, 10, ... in 20 of 20 iterations with assertions on and 19 of 20 with them off, with no assertion firing.
Exposure and likelihood379 words
  • Default, not opt-in. newSearcher(reader) returns an AssertingIndexSearcher every time. The slip does not depend on the index size or on -ea: every bounded forEach on a wrapped stream delivers the docs at or above upTo too. Whether a given stream reaches the collector wrapped depends on the scorer and, a little, on the seed: the scorer must hand over a DocIdStream (as BooleanScorer does for a disjunction); each layer replays about 1% of streams as collect(int) calls instead (AssertingLeafCollector.java:90); and in about 1% of cases AssertingWeight uses the default bulk scorer, which collects doc by doc (AssertingWeight.java:88-95). The study's forEach-only probe saw the extra docs in 20 of 20 iterations with assertions on and 19 of 20 with them off.
  • What goes wrong. Assertions off: wrong per-step results (for example wrong buckets or per-block counts). The extra docs are genuine docs of the same stream, so a collector that only totals the docs it is given still gets the right total; anything that depends on the step in which a doc arrives does not. Assertions on: a false AssertionError: exhausted on the next legal call, or, for a forEach-only loop, the same silent wrong steps.
  • In Lucene today. The only non-test caller of forEach(upTo, ...) is the sandbox HistogramCollector (HistogramCollector.java:327-347), and only its HistogramLeafCollector, which it picks for single-valued doc values with a skipper and a small bucket range (:102-120): it uses count(upTo) for runs inside one bucket (:336) and forEach(upTo, this::collect) otherwise (:338), then checks mayHaveRemaining() (:341). Its per-doc collect(int) puts the extra docs into the right buckets, so TestHistogramCollectorManager passes, but under AssertingIndexSearcher a wrapped stream ends after its first forEach(upTo), so a count(upTo) after a forEach(upTo) on the same stream is never reached there (read from the code).
  • Downstream (read only, not run): OpenSearch's HistogramSkiplistLeafCollector counts every doc that forEach(upToExclusive, ...) delivers into one bucket, and its AggregatorTestCase uses AssertingIndexSearcher on randomBoolean(). Whether its tests reach that branch through the wrapper was not checked.
  • Later reach. Open PR #16757 adds a DocIdStream.intoBitSet(upTo, ...) default that calls forEach(upTo, ...); its only caller passes NO_MORE_DOCS, so it does not trigger this today (inferred from the diff).

Minimal repro

One JUnit test, one method, deterministic, single thread: a 10-doc DocIdStream (docs 0-9) goes through one AssertingLeafCollector, whose collector calls forEach(5, ...). It sits in the wrapper's package because the wrapper is package-private, like the existing TestAssertingLeafCollector.

TestAssertingLeafCollectorForEachUpTo.java85 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.tests.search;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.search.DocIdStream;
import org.apache.lucene.search.LeafCollector;
import org.apache.lucene.search.Scorable;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.IOIntConsumer;

public class TestAssertingLeafCollectorForEachUpTo extends LuceneTestCase {

  /** The doc IDs 0 to 9, each consumed at most once. */
  private static class ZeroToTen extends DocIdStream {
    private int next;

    @Override
    public void forEach(int upTo, IOIntConsumer consumer) throws IOException {
      for (; next < Math.min(upTo, 10); next++) {
        consumer.accept(next);
      }
    }

    @Override
    public int count(int upTo) {
      int end = Math.max(next, Math.min(upTo, 10));
      int count = end - next;
      next = end;
      return count;
    }

    @Override
    public int intoArray(int upTo, int[] array) {
      int count = 0;
      while (count < array.length && next < Math.min(upTo, 10)) {
        array[count++] = next++;
      }
      return count;
    }

    @Override
    public boolean mayHaveRemaining() {
      return next < 10;
    }
  }

  public void testForEachUpTo() throws IOException {
    List<Integer> seen = new ArrayList<>();
    LeafCollector collector =
        new LeafCollector() {
          @Override
          public void setScorer(Scorable scorer) {}

          @Override
          public void collect(int doc) {}

          @Override
          public void collect(DocIdStream stream) throws IOException {
            stream.forEach(5, seen::add);
          }
        };
    // rarely() replays the stream as collect(int) calls instead of handing it over: retry
    while (seen.isEmpty()) {
      new AssertingLeafCollector(collector, 0, 10).collect(new ZeroToTen());
    }
    assertEquals("docs passed to forEach(upTo=5, ...)", List.of(0, 1, 2, 3, 4), seen);
  }
}
Command
cp $REPRO/TestAssertingLeafCollectorForEachUpTo.java $LUCENE/lucene/test-framework/src/test/org/apache/lucene/tests/search/
cd $LUCENE
./gradlew :lucene:test-framework:test --tests org.apache.lucene.tests.search.TestAssertingLeafCollectorForEachUpTo -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
Expected
forEach(5, ...) delivers docs [0, 1, 2, 3, 4] and leaves 5-9 in the stream.
Observed
It delivers [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], with assertions on and off.

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

More files, environment, full output335 words
  • Smallest input. The core streams are package-private in org.apache.lucene.search, so the test has a 4-method stream of its own over docs 0-9. The while loop only retries when collect(DocIdStream) takes its rarely() replay branch (about 1% of calls), which hands the docs over one by one instead of as a stream; with seed DEADBEEF it runs once.
  • Assertions. Pass -Ptests.asserts=true or false explicitly: Lucene's build otherwise picks the mode from the seed, separately for each module, and for the test-framework module DEADBEEF picks off. The test fails the same way in both modes.
  • Releases. The same test fails on every release from 10.2.0 to 10.6.0 with two API-shape edits: on 10.x the consumer type is CheckedIntConsumer<IOException> (IOIntConsumer is main-only, #14973), and before 10.4.0 the test stream must not override intoArray(int, int[]) (added by #15173).
  • Not in the repro: the nested consequences under AssertingIndexSearcher (the empty next step, the false "exhausted"); they come from the same line and the fix removes them (see Suggested fix).
  • As the regression test: this same method, for example added to TestAssertingLeafCollector (same package).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on every release 10.2.0 to 10.6.0, assertions on and off
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestAssertingLeafCollectorForEachUpTo.java $LUCENE/lucene/test-framework/src/test/org/apache/lucene/tests/search/
cd $LUCENE
./gradlew :lucene:test-framework:test --tests org.apache.lucene.tests.search.TestAssertingLeafCollectorForEachUpTo -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestAssertingLeafCollectorForEachUpTo > testForEachUpTo FAILED
    java.lang.AssertionError: docs passed to forEach(upTo=5, ...) expected:<[0, 1, 2, 3, 4]> but was:<[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:8A419BD4A7564B8B]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:120)
        at org.apache.lucene.tests.search.TestAssertingLeafCollectorForEachUpTo.testForEachUpTo(TestAssertingLeafCollectorForEachUpTo.java:83)
1 test completed, 1 failed
# -Ptests.asserts=false: the same AssertionError
# with the fix (A or B): BUILD SUCCESSFUL, 1 test

# under AssertingIndexSearcher (10 docs, forEach(5, ...) then count(), seed 5EED0205), assertions on:
java.lang.AssertionError: exhausted
    at org.apache.lucene.tests.search.AssertingLeafCollector$AssertingDocIdStream.count(AssertingLeafCollector.java:227)
    at org.apache.lucene.tests.search.AssertingLeafCollector$AssertingDocIdStream.count(AssertingLeafCollector.java:228)
    at org.apache.lucene.tests.search.AssertingLeafCollector.collect(AssertingLeafCollector.java:104)   (three such frames)
# the same, assertions off:
java.lang.AssertionError: newSearcher, iteration 0: docs at or above upTo=5 passed to forEach(upTo) expected:<0> but was:<5>

Why it looks unintended

  • The javadoc: forEach(upTo, ...) visits docs "up to the given upTo exclusive"; only forEach() and count() are terminal. Core tests keep reading after a bounded forEach.
  • The wrapper records lastUpTo = upTo; its siblings count(upTo) and intoArray(upTo, ...) pass the bound on.
  • In the PR that added it, a reviewer caught this slip in count(upTo); only that was fixed.
The full argument219 words
  • Contract. DocIdStream.java:41-46: "Iterate over doc IDs contained in this doc ID stream up to the given upTo exclusive, calling the given IOIntConsumer on them. It is not possible to iterate these doc IDs again later on." The unbounded forEach(consumer) and count() are each "a terminal operation" (:33-36, :48); the bounded methods are not, and mayHaveRemaining() (:77-81) exists so callers can keep reading.
  • Core tests. TestBitSetDocIdStream.testForEachUpTo (:80-106) calls stream.forEach(20, _ -> fail()), then forEach(100, ...) and forEach(200, ...) on the same stream; testMixForEachCountUpTo (:155) mixes bounded forEach and count; TestRangeDocIdStream does the same (:58, :118).
  • The wrapper's own bookkeeping. It asserts lastUpTo < upTo, records lastUpTo = upTo (not NO_MORE_DOCS) and checks exhaustion only when upTo == NO_MORE_DOCS (AssertingLeafCollector.java:210-222).
  • History. #14273 (jpountz, merged 2025-03-28) added the bounded API and this override together. In its first revision both forEach(upTo) and count(upTo) called the unbounded method; epotyom asked "Should it be count(upTo)?" (review comment r1966750525, 2025-02-23), and the "Review feedback" commit fixed only count(upTo). No comment, test or later commit discusses forEach(upTo).
  • Why tests miss it. TestAssertingLeafCollector never reads a stream through the wrapper's forEach(upTo); the core stream tests do not go through the wrapper; HistogramCollector absorbs the extra docs.
The devil's-advocate check157 words

Strongest case for "not a bug" (a separate intended-behaviour checker and a cross-model reviewer, OpenAI GPT-5.6, intended lens): the wrapper is package-private test-framework code, DocIdStream is @lucene.experimental, no non-sandbox caller exists, the asserting wrappers deliberately perturb batching so collectors cannot rely on batch boundaries, forEach(upTo) only promises that the docs below upTo are consumed, and "exhausted" correctly flags a collector that keeps reading after a terminal call. Both rated this argument weak.

Why it fails: perturbing batching (the rarely() replay, the random windows of AssertingBulkScorer) changes which docs arrive in a stream, but never lets a bounded call deliver docs at or above its bound; the javadoc says "up to the given upTo exclusive", and the collector's count() follows a bounded call, as the wrapper's own bookkeeping records. Experimental status and test-only reach bear on impact, not on whether it is a defect; the #14273 review treated the identical slip in count(upTo) as a mistake.

Suggested fix

Recommended A. Pass upTo on, and assert the bound in the lambda

Pass upTo on: stream.forEach(upTo, doc -> { ... }) at AssertingLeafCollector.java:211, and assert doc < upTo inside the lambda. Validated: the test fails before and passes after, with assertions on and off; the whole test-framework module (643 tests) and the sandbox histogram tests pass.

// AssertingLeafCollector.AssertingDocIdStream.forEach(int upTo, IOIntConsumer)
       assert lastUpTo < upTo : "upTo=" + upTo + " but previous upTo=" + lastUpTo;
       stream.forEach(
+          upTo,
           doc -> {
+            assert doc < upTo : "Out of range: " + doc + " >= upTo=" + upTo;
             assert doc > lastCollected : "Out of order : " + lastCollected + " " + doc;
All options and trade-offs2 options ยท 431 words

Two validated options, both applied in a scratch worktree of main 645d4fe14a20 and run with gradle. Not recommended: documenting forEach(upTo, ...) as terminal under the wrapper, which would contradict the DocIdStream javadoc, the core streams and their tests, and HistogramCollector's loop.

A. Pass upTo on, and assert the bound in the lambda (validated by the checks) (recommended)

Two added lines in AssertingDocIdStream.forEach(int upTo, IOIntConsumer).

// AssertingLeafCollector.AssertingDocIdStream.forEach(int upTo, IOIntConsumer)
       assert lastUpTo < upTo : "upTo=" + upTo + " but previous upTo=" + lastUpTo;
       stream.forEach(
+          upTo,
           doc -> {
+            assert doc < upTo : "Out of range: " + doc + " >= upTo=" + upTo;
             assert doc > lastCollected : "Out of order : " + lastCollected + " " + doc;

Trade-offs

  • Two lines, test framework only, no production cost.
  • The new assert also catches a wrapped stream that over-delivers, which is the wrapper's job, and mirrors the assert array[count - 1] < upTo of intoArray(upTo, ...) (AssertingLeafCollector.java:268).

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: the minimal test fails before the change and passes after it, with -Ptests.asserts=true and -Ptests.asserts=false.
  • The whole :lucene:test-framework:test (643 tests, 33 skipped, 0 failures) and the sandbox TestHistogramCollectorManager (7 tests; HistogramCollector is the only non-test bounded caller) pass with -Ptests.asserts=true. With assertions off, the whole :lucene:test-framework:test passes again (643 tests, 33 skipped; the seed's default for that module is off), and the independent fact check's runs with an explicit -Ptests.asserts=false pass TestHistogramCollectorManager (7) and TestAssertingLeafCollector (3).
  • Standalone on 645d4fe14a20: the study's public-API test under AssertingIndexSearcher and newSearcher passes 2/2, and a cross-model reviewer's own public-API test passes 10/10, each with assertions on and off.
  • Not run: gradlew check or other modules' tests. No other main or test source reads a wrapped stream with forEach(upTo) (a grep finds only HistogramCollector, and core tests that use core streams directly).

B. Pass upTo on only (validated by the checks)

Insert upTo, as the first argument of stream.forEach( at AssertingLeafCollector.java:211; the lambda stays unchanged.

Trade-offs

The smallest change (one token) and enough to restore the contract, but no check that the wrapped stream respects the bound.

What the checks validated

Gradle, seed DEADBEEF: the minimal test passes with assertions on and off; with -Ptests.asserts=true it passes together with TestAssertingLeafCollector (3 tests, so 4 test-framework tests in that run) and TestHistogramCollectorManager (7); with an explicit -Ptests.asserts=false (the independent fact check) TestAssertingLeafCollector (3) and TestHistogramCollectorManager (7) pass. Standalone, B shadowed onto 10.2.0 and 10.6.0 makes the minimal test pass, assertions on and off.

Downstream note

Downstream relevance only: test suites built on Lucene's test framework that use AssertingIndexSearcher with a bounded-step collector can see false failures or wrong per-step results; nothing changes in a deployed application.

How it was found

Semi-formal. While writing a model of the DocIdStream protocol, a hunt unit noticed the dropped bound in the code, then checked the real wrapper exhaustively against a reference built from the javadoc: every subset of an 8-doc window times every legal call sequence of up to 3 calls. Independent AI tools and a cross-model reviewer then reproduced it.

The method in full151 words
  • Model. A hunt unit (DocIdStream bulk-collect overrides, bounded) wrote a "sorted set + cursor" reference of the DocIdStream contract from the javadoc and checked it against the spec (0 mismatches over 3,772,928 sequences).
  • Translation validation. It drove the stock wrapper and the reference with every subset of an 8-doc window and every operation sequence of length up to 3 that the wrapper's own preconditions accept: 825,317 of 3,934,988 sequences mismatch with one wrapper layer and 1,024,899 of 3,895,215 with two nested layers; with the one-line fix, 0 and 0.
  • Reduction. The finder wrote a public-API JUnit through AssertingIndexSearcher and newSearcher (10 docs, forEach(5, ...) then count()), and the hunt later reduced it to the one-method test above (2026-10-29).
  • Verification. An independent reproducer (own tests, from the claim only), cross-model lenses (OpenAI GPT-5.6, refute and intended), an intended-behaviour devil's advocate and an adjudication all called it real; a credibility audit agreed.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-27 and 2026-11-01): GitHub issues and PRs incl. the JIRA archive, commits, mailing lists, downstream projects, the web, and the changed files of all 409 open PRs. Closest: a #14273 review comment that caught the same slip in the sibling count(upTo), fixed before the merge.

Searches4 searches

Closest, none this bug: the #14273 review comment r1966750525 (epotyom, "Should it be count(upTo)?"), on the sibling count(int upTo), fixed before the merge; #16757 (open, adds an intoBitSet(upTo) default over forEach(upTo), does not touch the wrapper); #14522 (a bounded-count fix in a core stream, since removed); #16102 / #16105 (a histogram-test failure under the asserting collector, different cause); #14088 and #13314 (the wrapper only in stack traces). Gaps: keyword search only; the JIRA archive predates DocIdStream; private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. migrated JIRA)2026-10-27 and 2026-11-01scripted searches on both dates (2026-10-27: two runs, 32 issue/PR queries in total; 2026-11-01: one run, 26), 26 manual GitHub searches (2026-10-27) and 24 GraphQL searches (2026-11-01) (class and method names, AssertingDocIdStream, forEach upTo, exhausted, histogram), every issue, PR and comment updated since 2026-10-24, all reviews and comments of #14273no report; closest the #14273 review comment on count(upTo)
Open PRs2026-10-27 and 2026-11-01the changed files of all 409 open apache/lucene PRsnone touches AssertingLeafCollector.java
Mailing lists (dev@, java-user@, issues@, commits@, builds@) and the JIRA archive2026-10-27 and 2026-11-01scripted queries (40 list and 8 JIRA, then 20 and 4) and 8 + 12 lists.apache.org queriesonly notification mails for #14273, #14401 and #331
Downstream projects and the web2026-10-27 and 2026-11-01elastic/elasticsearch, OpenSearch, solr and GitHub-wide searches; 4 and 6 web queriesnothing relevant

History

Introduced by #14273 (82200c0f4f7, merged 2025-03-28), which added the bounded DocIdStream API and this override together; first released in 10.2.0. Fails on every release 10.2.0 to 10.6.0 and on main, assertions on and off; 10.1.0 has no bounded API. Not fixed upstream.

Versions tested5 rows
  • Introduced: 82200c0f4f7 "Speed up histogram collection in a similar way as disjunction counts. (#14273)" (jpountz, reviewed by epotyom, approved by gsmiller, merged 2025-03-28, milestone 10.2.0), backported the same day as 0018e62cf21. git log -S 'forEach(int upTo' over the two files finds only these commits. The unbounded call is in the PR's first revision, so this is an original defect, not a regression; #14973 (47f3387e67d, main only) changed only the consumer type.
  • Bytecode: in the 10.2.0 and 10.6.0 test-framework jars, forEach(int, CheckedIntConsumer) invokes DocIdStream.forEach:(CheckedIntConsumer)V and then stores upTo into lastUpTo.
  • Upstream: upstream main f815e679bf6c (2026-11-01), branch_11x d0c0decc6606 and branch_10_6 91eaab4fb298 make the same call; the last commit to the file is #16144 (2026-05-31), which does not touch this method.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (gradle and standalone); passes with fix A or B; nested consequences re-run
10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2 affectedfail 1/1 with assertions on and off (variant without intoArray)
10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 affectedfail 1/1 with assertions on and off (10.x variant)
10.1.0 (and 10.0.0, 9.12.3) not affecteddoes not compile: no bounded DocIdStream API
10.2.0 and 10.6.0 + fix B fixpass 1/1 with assertions on and off

Independent checks

Every check calls it real: the finder, an independent reproducer, cross-model lenses (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication, a credibility audit, the current-main verification, a second cross-model review on 2026-11-01, this doc's gradle runs and an independent fact check.

Checks and verdicts386 words

The checks of 2026-10-26/27 ran on main a9779ef3c193, except the cross-model lenses, which ran on the previous main ea57360912ed, as did the first runs of the 2026-11-01 cross-model review. AssertingLeafCollector.java and DocIdStream.java are byte-identical in these commits and in 645d4fe14a20. All other main runs from 2026-10-29 on (the minimal repro, the current-main verification, the history matrix, this doc's gradle runs, the cross-model re-run and the fact check) used 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedPublic-API JUnit fails 2/2 with assertions on and off (20/20 with --iters 10); plain control passes; 2/2 pass with the fix; exhaustive check 0 mismatches with the fix.
independent reproducer realOwn tests fail 3/5 on main, 10.5.2 and 10.2.0 with assertions on and off (both controls pass); 5/5 with its own one-line fix.
cross-model lenses (refute, intended) real, highOpenAI GPT-5.6, executed on the previous main ea57360912ed; own public-API tests; the intended-behaviour argument weak.
devil's advocate real, highBuilt and rejected the strongest intended case; found the quiet forEach-only symptom.
adjudication confirmed, high, minorRe-ran the independent reproduction's repro with a fresh seed (3/5 fail, 5/5 with the fix) and read the #14273 review comment.
credibility audit reproducesFresh-seed re-runs; impact class minor kept.
current-main verification realMinimal test on 645d4fe14a20 and 10.5.2, both assertion modes; nested consequences re-run on 645d4fe14a20.
cross-model review, refute lens (2026-11-01) real, highOpenAI GPT-5.6: own sparse-stream and 64-doc public-API tests on the previous main ea57360912ed (upTo=17 received docs 0-63); re-run on 645d4fe14a20, 5 asserting trials fail and 5 plain controls pass; 10/10 with the fix.
doc gradle runs fails before, passes with A and BScratch worktree of 645d4fe14a20; the whole test-framework module and the histogram tests pass with A.
independent fact check (2026-11-01) real; two doc errors found and fixedOn 645d4fe14a20: the minimal test fails standalone and through gradle with assertions on and off; fixes A and B each make it pass; with an explicit -Ptests.asserts=false the histogram tests (7) and TestAssertingLeafCollector (3) pass under each fix; an instrumented copy shows the retry loop runs once with DEADBEEF. Its two errors (the assertions-off label of the histogram runs, and the commit of the earlier checks) are corrected in this doc.

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

Finding record

LFV-664, snapshot of 2026-11-06

Registry title
Test framework: AssertingLeafCollector's DocIdStream wrapper ignores upTo in forEach(int upTo, IOIntConsumer) and passes every remaining doc to the consumer, so a collector that reads a stream in bounded steps under AssertingIndexSearcher (the default of LuceneTestCase.newSearcher) gets wrong per-step results with -da and a spurious 'exhausted' AssertionError on its next legal call with -ea
Area
test-framework / search: AssertingLeafCollector DocIdStream wrapper (AssertingIndexSearcher, bulk collection)
Priority
Low, rank 1221: This test-framework bug (AssertingIndexSearcher ignores DocIdStream.forEach's upTo bound) affects only collectors that read the stream in bounded steps and breaks no in-tree test.
Impact class
minor
Impact note
Test code only, in Lucene or downstream: a collector whose collect(DocIdStream) reads the stream in bounded steps with forEach(int upTo, ...) (an @lucene.experimental API since 10.2.0) and is run under AssertingIndexSearcher, which LuceneTestCase.newSearcher returns by default (with -ea or -da), receives docs at or above upTo in its first step and nothing in later steps; with -da, and even with -ea for a loop that only calls forEach(upTo), this is silent (wrong per-step results such as wrong buckets), and with -ea a following legal count() or forEach() fails the nested wrapper's 'exhausted' assertion; any index size, deterministic; production IndexSearcher and the core DocIdStream implementations are correct, and the only in-tree caller (the sandbox HistogramCollector) absorbs the extra docs through its per-doc fallback, so no in-tree test fails, but its count(upTo)-after-forEach(upTo) branch never runs under AssertingIndexSearcher.
Summary
In a Lucene test you search with LuceneTestCase.newSearcher (an AssertingIndexSearcher) and a collector whose collect(DocIdStream) first calls stream.forEach(5, consumer) and then stream.count() for the rest, which the DocIdStream javadoc allows ('up to the given upTo exclusive ... It is not possible to iterate these doc IDs again later on'). You expect the split a plain IndexSearcher gives: docs 0-4 in the first step and docs 5-9 counted afterwards. Instead the test framework's wrapper calls the wrapped stream's unbounded forEach, so the first step also receives docs 5-9 and the second step gets nothing (with -da), and with -ea the nested wrapper then rejects the legal count() with AssertionError 'exhausted'.
Introduced
82200c0f4f7396b0312fe7b85eccc3f09453af4f on main, apache/lucene#14273 'Speed up histogram collection in a similar way as disjunction counts.' (GitHub jpountz; reviewers epotyom and gsmiller, approved by gsmiller), backported to branch_10x as 0018e62cf21fe589633b01812fe9ec49fd42b426 (same AssertingLeafCollector.java blob ca05ef76f878) [read: git log -S 'forEach(int upTo' over DocIdStream.java and AssertingLeafCollector.java on the full-history clone finds only this PR; git blame puts the method body on 82200c0f4f7, and 47f3387e67d (#14973, 2025-07-23) only changed the consumer type CheckedIntConsumer<IOException> -> IOIntConsumer]. The PR added the bounded DocIdStream.forEach(int upTo, ...) and the wrapper override together; the unbounded call is already in the PR's first revision 0ca1b737de65 and never changed, so this is an original defect, not a regression (2025-03-28)
First affected version
10.2.0 [exec, -ea and -da: the finder's JUnit fails 2/2], the first release with DocIdStream.forEach(int upTo, ...); 10.1.0, 10.0.0 and 9.12.3 do not have that method [exec: the repro does not compile, API absent; javap of each core jar confirms]
Affected versions
every release from 10.2.0 through 10.6.0 (executed: 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2; on 10.6.0 and main 645d4fe14a20 the minimal repro fails with -ea and -da, 2026-11-01) and main a9779ef3c193; never fixed. [exec] the finder's JUnit (seed 5EED0205) fails 2/2 with -ea and with -da on each of those releases and on main (follow-up); the independent reproduction executed 10.2.0 and 10.5.2 (-ea and -da) and the intended-behaviour check 10.5.2 (-ea and -da) with the same result; the one-line fix shadowed onto 10.2.0 and 10.5.2 passes 2/2 with -ea and -da. [read] upstream main a95c754ee003, branch_10x 657014b3c579 and branch_10_6 (10.6.0, released 2026-10-29) 4ae946e5a9b9 still make the unbounded call (2026-10-26/27); the last commit to the file on upstream main is 14a3b87e75e0 (#16144, 2026-05-31), which does not touch this method (rechecked read-only 2026-10-27)
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check, code reading
Upstream and prior reports
none found (2026-10-27): two scripted search passes (32 GitHub issue/PR, 6 commit, 4 downstream, 40 mailing-list over dev@, java-user@, issues@ and commits@, 8 JIRA-archive queries), 26 manual GitHub searches (apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, GitHub-wide), 2 commit searches, the changed-file lists of all 409 open apache/lucene PRs (none touches AssertingLeafCollector.java), 8 lists.apache.org queries, 4 web searches, and direct reads of about 20 issues and PRs including all reviews and comments of #14273. Closest item, not this bug: in the introducing PR #14273, review comment 1966750525 by epotyom (2025-02-23) asked 'Should it be count(upTo)?' about the sibling override count(int upTo), which also dropped upTo in the first revision; that was fixed before merge (b37b9e8fedb5, 'Review feedback'), and nobody mentioned forEach(int upTo, ...). Related, not this bug: open PR #16757 adds DocIdStream.intoBitSet(upTo, ...) whose default calls forEach(upTo, ...), without touching the wrapper; its only caller passes NO_MORE_DOCS, so it neither fixes nor triggers this (a later bounded caller would reach the bug through that default; inferred from the code, not run); #16102 (fixed by #16105) was a histogram-test failure under the asserting collector with a different cause; #14088 and #13314 only show the wrapper in stack traces. Gaps: keyword search only; JIRA predates DocIdStream; web search coverage was weak; private reports cannot be searched
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test through gradle with assertions on and off, before and after fixes A and B, in a scratch worktree (2026-11-01), plus the whole :lucene:test-framework:test and the sandbox TestHistogramCollectorManager with fix A; standalone, the minimal test on every release 10.2.0 to 10.6.0 (assertions on and off; 10.1.0 does not compile), and the nested AssertingIndexSearcher consequences on 645d4fe14a20. Upstream main f815e679bf6c (2026-11-01) makes the same call.

Lucene and upstream sources

Finding record
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingLeafCollector.java:89-109 (at a9779ef3c193) (collect(DocIdStream): rarely() at :90 decomposes the stream into collect(int) calls, otherwise :104 passes new AssertingDocIdStream(stream) to the wrapped collector), :193-206 (forEach(IOIntConsumer): terminal, 'exhausted' assert at :195), :208-223 (forEach(int upTo, IOIntConsumer): :210 asserts lastUpTo < upTo, :211 calls the unbounded stream.forEach(consumer), :219 sets lastUpTo = upTo, :220-222 checks exhaustion only for NO_MORE_DOCS), :225-232 (count(): 'exhausted' assert at :227), :234-243 (count(int upTo) forwards upTo at :237), :259-282 (intoArray(int upTo, int[]) forwards upTo at :263); file byte-identical on Lucene main ea57360912ed
  • lucene/core/src/java/org/apache/lucene/search/DocIdStream.java:22-27 (at a9779ef3c193) ('Doc IDs may be consumed at most once', @lucene.experimental), :33-39 (forEach(consumer) is 'a terminal operation' and delegates to forEach(NO_MORE_DOCS, ...)), :41-46 (forEach(int upTo, ...): 'Iterate over doc IDs contained in this doc ID stream up to the given upTo exclusive ... It is not possible to iterate these doc IDs again later on.'), :53-59 (count(int upTo)); file byte-identical on Lucene main ea57360912ed
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:2551-2553 (at a9779ef3c193) (newSearcher(reader) -> wrapWithAssertions true), :2626-2630 (wrapWithAssertions -> AssertingIndexSearcher); lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingBulkScorer.java:64, :79 and AssertingCollector.java:66 (each wraps the collector in an AssertingLeafCollector, so AssertingIndexSearcher nests the stream wrapper)
  • lucene/core/src/test/org/apache/lucene/search/TestBitSetDocIdStream.java:80-106 (at a9779ef3c193) (testForEachUpTo: forEach(20, _ -> fail()) then further bounded calls on the same stream), :155+ (testMixForEachCountUpTo), TestRangeDocIdStream.java:63+; lucene/test-framework/src/test/org/apache/lucene/tests/search/TestAssertingLeafCollector.java (no test calls the wrapper's forEach(upTo); its forEach(int upTo, ...) at :100 is a stub stream)
  • lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollector.java:327-347 (at a9779ef3c193) (collect(DocIdStream): loop of count(upTo) at :336 or forEach(upTo, this::collect) at :338, then mayHaveRemaining(); the only in-tree forEach(upTo) caller, per-doc fallback)
  • downstream, read-only GitHub API 2026-10-27: opensearch-project/OpenSearch server/src/main/java/org/opensearch/search/aggregations/bucket/HistogramSkiplistLeafCollector.java collect(DocIdStream, long) (stream.forEach(upToExclusive, ...) counted into upToBucketIndex, last commit dfef2c18fca8 2025-12-08) and test/framework/src/main/java/org/opensearch/search/aggregations/AggregatorTestCase.java:1106-1111 (AssertingIndexSearcher on randomBoolean()); first pointed out by the intended-behaviour check; not executed
The finding in 30 seconds
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingLeafCollector.java:89-109, :184-290 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/DocIdStream.java:22-81 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1307-1316, :2551-2558, :2626-2630 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingCollector.java:66; AssertingBulkScorer.java:64, :79; AssertingIndexSearcher.java:76 (at 645d4fe14a20)
Who is affected
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:2551-2630 (at 645d4fe14a20)
  • lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollector.java:327-347 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingWeight.java:88-95; lucene/core/src/java/org/apache/lucene/search/Weight.java:293-361 (DefaultBulkScorer collects doc by doc); HistogramCollector.java:102-120 (at 645d4fe14a20)
Minimal repro
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingLeafCollector.java:90 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/DocIdStream.java:33-81 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/search/TestBitSetDocIdStream.java:80-106, :155-189; TestRangeDocIdStream.java:58, :118 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingLeafCollector.java:208-243, :259-282 (at 645d4fe14a20)
Suggested fix
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingLeafCollector.java:259-282 (at 645d4fe14a20)
Diagrams
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingLeafCollector.java:208-223 (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.