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.
- A
DocIdStreamhands a collector a batch of matching doc ids at once (LeafCollector.collect(DocIdStream)). Each doc can be consumed only once. - It can be read in steps:
forEach(upTo, consumer)andcount(upTo)take only the docs belowupTo;forEach(consumer)andcount()take everything that is left ("terminal"). - In tests,
LuceneTestCase.newSearcherreturns anAssertingIndexSearcher. 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.
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.- 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, thenstream.count()for the rest. You search withnewSearcher(reader), which returns anAssertingIndexSearcher. - 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 plainIndexSearchergives exactly that. - 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. - With assertions off, the next step gets nothing. With assertions on, the next legal
count()fails withAssertionError: exhausted. A collector that only loopsforEach(upTo)gets the extra docs silently, even with assertions on.
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.
@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)isforEach(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)passeswrapWithAssertions = trueand always builds anAssertingIndexSearcher(LuceneTestCaseParent.java:2551-2558,:2626-2630). It wraps the collector in anAssertingCollector(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. EachAssertingLeafCollector.collect(DocIdStream)(:89-109) hands onnew 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 ownlastUpTo = NO_MORE_DOCS(:204). The outer layer recorded onlylastUpTo = 5, so it lets the collector's nextcount()through (:228), and the middle layer'sassert lastUpTo != NO_MORE_DOCS : "exhausted"fires (:227;:195forforEach()): in the trace,count:227is called fromcount: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)callsstream.count(upTo)(:237);intoArray(int upTo, int[])callsstream.intoArray(upTo, array)(:263) and assertsarray[count - 1] < upTo(:268).
- 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-termSHOULDdisjunction collected withCOMPLETE_NO_SCORES,forEach(5, ...)thencount()): assertions on,AssertionError: exhaustedatAssertingLeafCollector.java:227under threecollect(:104)frames; assertions off, 5 docs at or aboveupToreach the first step. An independent test with 12 docs,forEach(4, ...)thenforEach(...), saw[0..11]in the first step and[]in the second (assertions off). A plainIndexSearchergives 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 anAssertingIndexSearcherevery time. The slip does not depend on the index size or on-ea: every boundedforEachon a wrapped stream delivers the docs at or aboveupTotoo. Whether a given stream reaches the collector wrapped depends on the scorer and, a little, on the seed: the scorer must hand over aDocIdStream(asBooleanScorerdoes for a disjunction); each layer replays about 1% of streams ascollect(int)calls instead (AssertingLeafCollector.java:90); and in about 1% of casesAssertingWeightuses the default bulk scorer, which collects doc by doc (AssertingWeight.java:88-95). The study'sforEach-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: exhaustedon the next legal call, or, for aforEach-only loop, the same silent wrong steps. - In Lucene today. The only non-test caller of
forEach(upTo, ...)is the sandboxHistogramCollector(HistogramCollector.java:327-347), and only itsHistogramLeafCollector, which it picks for single-valued doc values with a skipper and a small bucket range (:102-120): it usescount(upTo)for runs inside one bucket (:336) andforEach(upTo, this::collect)otherwise (:338), then checksmayHaveRemaining()(:341). Its per-doccollect(int)puts the extra docs into the right buckets, soTestHistogramCollectorManagerpasses, but underAssertingIndexSearchera wrapped stream ends after its firstforEach(upTo), so acount(upTo)after aforEach(upTo)on the same stream is never reached there (read from the code). - Downstream (read only, not run): OpenSearch's
HistogramSkiplistLeafCollectorcounts every doc thatforEach(upToExclusive, ...)delivers into one bucket, and itsAggregatorTestCaseusesAssertingIndexSearcheronrandomBoolean(). Whether its tests reach that branch through the wrapper was not checked. - Later reach. Open PR #16757 adds a
DocIdStream.intoBitSet(upTo, ...)default that callsforEach(upTo, ...); its only caller passesNO_MORE_DOCS, so it does not trigger this today (inferred from the diff).
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.
/*
* 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);
}
}
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.
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. Thewhileloop only retries whencollect(DocIdStream)takes itsrarely()replay branch (about 1% of calls), which hands the docs over one by one instead of as a stream; with seedDEADBEEFit runs once. - Assertions. Pass
-Ptests.asserts=trueorfalseexplicitly: Lucene's build otherwise picks the mode from the seed, separately for each module, and for the test-framework moduleDEADBEEFpicks 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>(IOIntConsumeris main-only, #14973), and before 10.4.0 the test stream must not overrideintoArray(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).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on every release 10.2.0 to 10.6.0, assertions on and off |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)
# 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>- The javadoc:
forEach(upTo, ...)visits docs "up to the given upTo exclusive"; onlyforEach()andcount()are terminal. Core tests keep reading after a boundedforEach. - The wrapper records
lastUpTo = upTo; its siblingscount(upTo)andintoArray(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 givenupToexclusive, calling the givenIOIntConsumeron them. It is not possible to iterate these doc IDs again later on." The unboundedforEach(consumer)andcount()are each "a terminal operation" (:33-36,:48); the bounded methods are not, andmayHaveRemaining()(:77-81) exists so callers can keep reading. - Core tests.
TestBitSetDocIdStream.testForEachUpTo(:80-106) callsstream.forEach(20, _ -> fail()), thenforEach(100, ...)andforEach(200, ...)on the same stream;testMixForEachCountUpTo(:155) mixes boundedforEachandcount;TestRangeDocIdStreamdoes the same (:58,:118). - The wrapper's own bookkeeping. It asserts
lastUpTo < upTo, recordslastUpTo = upTo(notNO_MORE_DOCS) and checks exhaustion only whenupTo == 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)andcount(upTo)called the unbounded method; epotyom asked "Should it becount(upTo)?" (review comment r1966750525, 2025-02-23), and the "Review feedback" commit fixed onlycount(upTo). No comment, test or later commit discussesforEach(upTo). - Why tests miss it.
TestAssertingLeafCollectornever reads a stream through the wrapper'sforEach(upTo); the core stream tests do not go through the wrapper;HistogramCollectorabsorbs 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.
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.
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] < upToofintoArray(upTo, ...)(AssertingLeafCollector.java:268).
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seedDEADBEEF: the minimal test fails before the change and passes after it, with-Ptests.asserts=trueand-Ptests.asserts=false. - The whole
:lucene:test-framework:test(643 tests, 33 skipped, 0 failures) and the sandboxTestHistogramCollectorManager(7 tests;HistogramCollectoris the only non-test bounded caller) pass with-Ptests.asserts=true. With assertions off, the whole:lucene:test-framework:testpasses 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=falsepassTestHistogramCollectorManager(7) andTestAssertingLeafCollector(3). - Standalone on
645d4fe14a20: the study's public-API test underAssertingIndexSearcherandnewSearcherpasses 2/2, and a cross-model reviewer's own public-API test passes 10/10, each with assertions on and off. - Not run:
gradlew checkor other modules' tests. No other main or test source reads a wrapped stream withforEach(upTo)(a grep finds onlyHistogramCollector, and core tests that use core streams directly).
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 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.
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
DocIdStreamcontract 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
AssertingIndexSearcherandnewSearcher(10 docs,forEach(5, ...)thencount()), 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, incl. migrated JIRA) | 2026-10-27 and 2026-11-01 | scripted 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 #14273 | no report; closest the #14273 review comment on count(upTo) |
| Open PRs | 2026-10-27 and 2026-11-01 | the changed files of all 409 open apache/lucene PRs | none touches AssertingLeafCollector.java |
| Mailing lists (dev@, java-user@, issues@, commits@, builds@) and the JIRA archive | 2026-10-27 and 2026-11-01 | scripted queries (40 list and 8 JIRA, then 20 and 4) and 8 + 12 lists.apache.org queries | only notification mails for #14273, #14401 and #331 |
| Downstream projects and the web | 2026-10-27 and 2026-11-01 | elastic/elasticsearch, OpenSearch, solr and GitHub-wide searches; 4 and 6 web queries | nothing relevant |
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 as0018e62cf21.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)invokesDocIdStream.forEach:(CheckedIntConsumer)Vand then storesupTointolastUpTo. - Upstream: upstream main
f815e679bf6c(2026-11-01), branch_11xd0c0decc6606and branch_10_691eaab4fb298make the same call; the last commit to the file is #16144 (2026-05-31), which does not touch this method.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal 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 | affected | fail 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 | affected | fail 1/1 with assertions on and off (10.x variant) |
| 10.1.0 (and 10.0.0, 9.12.3) | not affected | does not compile: no bounded DocIdStream API |
| 10.2.0 and 10.6.0 + fix B | fix | pass 1/1 with assertions on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Public-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 | real | Own 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, high | OpenAI GPT-5.6, executed on the previous main ea57360912ed; own public-API tests; the intended-behaviour argument weak. |
| devil's advocate | real, high | Built and rejected the strongest intended case; found the quiet forEach-only symptom. |
| adjudication | confirmed, high, minor | Re-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 | reproduces | Fresh-seed re-runs; impact class minor kept. |
| current-main verification | real | Minimal 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, high | OpenAI 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 B | Scratch 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 fixed | On 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 439 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,376 words
- 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:testand the sandboxTestHistogramCollectorManagerwith 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 nestedAssertingIndexSearcherconsequences on645d4fe14a20. Upstream mainf815e679bf6c(2026-11-01) makes the same call.
- 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 ea57360912edlucene/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 ea57360912edlucene/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)
- TestAssertingLeafCollectorForEachUpTo.java:
repro/LFV-664/src/org/apache/lucene/tests/search/TestAssertingLeafCollectorForEachUpTo.java(85 lines)
assets/LFV-664/bug.png(439 KB)
