Range grouping asks a factory for each document's bucket and uses that bucket, a [min, max) range, as the group's value. The factory never ends the last bucket below max at max.
GroupingSearchgroups hits by one value per document.LongRangeGroupSelectorcomputes that value withLongRangeFactory.getRange(value);DoubleRangeGroupSelectordoes the same for doubles.new LongRangeFactory(min, width, max)makes one bucket belowmin, buckets ofwidthfromminup tomax(the last one may be shorter), and one bucket for everything at or abovemax. Each bucket is aLongRangefrom an inclusive minimum to an exclusive maximum.- The bucket comes back as the group's value (
GroupDocs.groupValue()), so applications show it as a heading or turn it into a range query.
LongRangeFactory(10, 10, 25) on a number line, with the prices 12, 22, 27 and 40: the label LongRange(20, 30) runs past max 25 into LongRange(25, MAX). Select the still to open it at full size. Generated with AI, checked against the code.- Group four documents with prices 12, 22, 27 and 40 by price, with
LongRangeGroupSelectorandnew LongRangeFactory(10, 10, 25): buckets 10 wide starting at 10, and one bucket for everything at or above 25. - You expect what the javadoc says:
[10, 20), then a shorter final bucket[20, 25)("the final range which may be up to this width"), then[25, MAX). Three groups whose ranges do not overlap. - You get three groups with the right members, {12}, {22} and {27, 40}, but the middle one is labelled
LongRange(20, 30):getRangeends every bucket at its start + 10 and never cuts it at max 25, so it overlapsLongRange(25, MAX)on 25 to 29. - Anything built from that label is wrong: the drill-down
price:[20 TO 29]from the group's bounds, the way Lucene's own selector tests rebuild a group, returns 22 and 27, though 27 is in the next group. DoubleRangeFactorydoes the same. Only a max that is not min + k x width triggers it (ages 18 to 65 by 10 giveLongRange(58, 68)next toLongRange(65, MAX)), in every release since 8.6.0.
Applications that group by numeric ranges with LongRangeGroupSelector or DoubleRangeGroupSelector (opt-in) and a factory whose max is not min + k x width, such as ages 18 to 65 by 10. Every search: the label is per-value arithmetic, independent of the index. Membership, counts and order stay right; the last bucket's label, and queries built from it, are wrong.
Mechanism, terms and numbers628 words
LongRangeFactory(min, width, max) and DoubleRangeFactory turn a numeric value into the range that LongRangeGroupSelector and DoubleRangeGroupSelector group by. Their javadoc promises ranges of width between min and max, "with the exception of the final range which may be up to this width", each inclusive at the lower end and exclusive at the upper end, and one range for everything above max. getRange never shortens the final range: it always sets the bucket's upper bound to its lower bound + width, while values >= max go to [max, MAX_VALUE). Whenever max - min is not a multiple of width, the last bucket below max is therefore labelled a full width wide and overlaps the next one: new LongRangeFactory(10, 10, 25) labels 22 as LongRange(20, 30) and 27 as LongRange(25, MAX). Group membership, counts and order are right; only that label is wrong, and a range query built from it, such as a drill-down, also returns the other group's documents. Present since the classes were added in 8.6.0, in every release through 10.6.0 and on main; a small fix (one code line for doubles, two for longs) ends the bucket at max.
LongRangeFactory.getRangecomputes the bucket and never caps its end (LongRangeFactory.java:62-64;DoubleRangeFactory.java:62-64is the same withMath.floor):
long bucket = (value - min) / width;
reuse.min = min + (bucket * width);
reuse.max = reuse.min + width; // never capped at max- Values at or above
maxget[max, Long.MAX_VALUE)(:57-61;Double.MAX_VALUEfor doubles). - With an unaligned
max, the last in-range label[k, k + width)therefore contains[max, k + width), where the "values >= max" label starts. With an alignedmax(k + width == max) this overlap does not occur (for doubles: integral values, as tested; decimal widths have a separate rounding issue, LFV-412). - Membership is decided by label equality, so it stays right:
LongRangeGroupSelectorcallsgetRangeonce per document (LongRangeGroupSelector.java:72) and keeps a document in the second pass if its label is one of the top groups (:76);LongRangeandDoubleRangecompare both bounds (LongRange.java:42-53,DoubleRange.java:44-55). Only the reported bounds of that one bucket are wrong. - The constructors accept any
(min, width, max)without validation (LongRangeFactory.java:36-40,DoubleRangeFactory.java:36-40). This page assumes a positivewidth,min < max, finite double parameters and nolongoverflow invalue - min(awidthof 0, for example, is alongdivision by zero).
LongRangeFactory(10, 10, 25): 20 to 24 getLongRange(20, 30), 25 to 40 getLongRange(25, MAX);DoubleRangeFactory(10, 10, 25)givesDoubleRange(20.0, 30.0)andDoubleRange(25.0, Double.MAX_VALUE).- Prices 12, 22, 27, 40 grouped with that factory: groups {12}, {22}, {27, 40}; the drill-down
price:[20 TO 29]from the labelLongRange(20, 30)returns 22 and 27. With the recommended fix the label isLongRange(20, 25)and the drill-downprice:[20 TO 24]returns 22 only. - A bounded-exhaustive check of the real factories (min and max in [-6, 6] with min < max, width 1 to 13, values -20 to 20; 1,014 configurations per factory) finds 823 configurations per factory that break the javadoc's partition, every one with
(max - min) % width != 0, and none with the fix.
DoubleRangeFactorylabels the below-min bucket withDouble.MIN_VALUEas its lower bound (DoubleRangeFactory.java:54; LFV-411).- With a decimal width,
DoubleRangeFactoryrounds the bucket choice and the bounds separately, so a returned range can miss its value:DoubleRangeFactory(0, 0.1, 10).getRange(0.6)isDoubleRange(0.5, 0.6)(LFV-412). Capping atmaxdoes not change that. LongRangeFactorycomputesvalue - minwithout an overflow check, and[max, Long.MAX_VALUE)cannot containLong.MAX_VALUE(a representation limit).
Exposure and likelihood233 words
- Who. Range grouping through
GroupingSearch(Supplier)or the grouping collector managers with an unaligned factory. Ordinary settings such as ages 18 to 65 by 10 or prices 0 to 950 by 100 are unaligned, the case the javadoc explicitly allows ("the final range which may be up to this width"); the examples are ours, not the javadoc's. A read-only code search on 2026-10-26 found no use of the two factories or the two range selectors in Elasticsearch, Solr or OpenSearch (default branches). - What goes wrong. On every search, with assertions on or off (the label is computed per document from its value, so the index and its size do not matter): the last bucket below
maxis reported up to one width too wide. A UI shows "20-30" next to "25 and up"; a drill-down that turns the label into a range filter returns that bucket's documents plus those in[max, reported max)that pass its other clauses, which belong to the "values >= max" group of the same results. - What stays right. Which documents are in which group, group counts, group sort and the documents within each group. Aligned factories, the only ones in Lucene's tests, are unaffected.
- How likely. Plausible within a rarely used API: no report since 8.6.0 (2020), but the trigger is a plain documented setting and shows as soon as the label is displayed or turned into a query.
One JUnit class in lucene/grouping, public API, no index: two one-line tests, one per factory (two fix sites), each asserting getRange(22) of a (10, 10, 25) factory. Deterministic; it fails the same way on main, 10.6.0, 10.5.2 and 9.12.3.
/*
* 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.search.grouping;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestRangeFactoryFinalRange extends LuceneTestCase {
// 22 is in the final range [20, 25) below max 25 ("may be up to this width"); actual [20, 30)
public void testLongRangeFactory() {
assertEquals(new LongRange(20, 25), new LongRangeFactory(10, 10, 25).getRange(22, null));
}
public void testDoubleRangeFactory() {
assertEquals(new DoubleRange(20, 25), new DoubleRangeFactory(10, 10, 25).getRange(22, null));
}
}
cp $REPRO/org/apache/lucene/search/grouping/TestRangeFactoryFinalRange.java \
$LUCENE/lucene/grouping/src/test/org/apache/lucene/search/grouping/
cd $LUCENE && ./gradlew :lucene:grouping:test --tests org.apache.lucene.search.grouping.TestRangeFactoryFinalRange \
-Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false (same failures); the test uses no randomness
- Expected
LongRange(20, 25)andDoubleRange(20.0, 25.0): the final range below max 25 ends at 25.- Observed
LongRange(20, 30)andDoubleRange(20.0, 30.0), overlapping[25, MAX): both tests fail, assertions on and off.
More files, environment, full output228 words
Same package as Lucene's grouping tests. The two factories are separate test methods because they are separate fix sites: with both assertions in one method, the DoubleRangeFactory one would never run on stock main. The seed only picks the test framework's locale and settings, which the test does not use. The same test is the suggested regression test.
End to end through GroupingSearch (not part of the repro): with documents 22 and 27, the group LongRange(20, 30) holds 1 document while LongPoint.newRangeQuery("v", 20, 29) over its bounds counts 2 (documents in LongRange(20, 30) expected:<1> but was:<2>, the hunt's test, re-run on 645d4fe14a20).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0, 10.5.2 and 9.12.3 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code is pure arithmetic) |
cp $REPRO/org/apache/lucene/search/grouping/TestRangeFactoryFinalRange.java \
$LUCENE/lucene/grouping/src/test/org/apache/lucene/search/grouping/
cd $LUCENE && ./gradlew :lucene:grouping:test --tests org.apache.lucene.search.grouping.TestRangeFactoryFinalRange \
-Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false (same failures); the test uses no randomness
# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF, -Ptests.asserts=true (identical with =false)
TestRangeFactoryFinalRange > testLongRangeFactory FAILED
java.lang.AssertionError: expected:<LongRange(20, 25)> but was:<LongRange(20, 30)>
TestRangeFactoryFinalRange > testDoubleRangeFactory FAILED
java.lang.AssertionError: expected:<DoubleRange(20.0, 25.0)> but was:<DoubleRange(20.0, 30.0)>
2 tests completed, 2 failed
# standalone runner: the same 2 failures on main (-ea, -da), 10.6.0 (-ea, -da), 10.5.2 and 9.12.3
# with the fix: 2 tests, 0 failures (assertions on and off)- Javadoc: ranges are
widthwide "with the exception of the final range which may be up to this width". LongRangeis documented as[min, max);LongRange(20, 30)does not hold 25 to 29, which go toLongRange(25, MAX).- Lucene's selector tests rebuild each group from its bounds with a range query; all their factories are aligned,
(100, 100, 900).
The full argument250 words
- The javadoc describes this case.
LongRangeFactory.java:31-34(andDoubleRangeFactory.java): "@param width a standard width; all ranges between {@code min} and {@code max} are this wide, with the exception of the final range which may be up to this width. Ranges are inclusive at the lower end, and exclusive at the upper end." and "@param max a maximum value; all longs above this value are grouped into a single range". The final-range clause only matters for an unalignedmax, and with the current code it can never apply. - The range classes.
LongRange.java:22: "Represents a contiguous range of long values, with an inclusive minimum and exclusive maximum";:28: "The exclusive maximum value of this range" (DoubleRange.java:22-31the same). - Lucene's tests use the bounds as a range.
BaseGroupSelectorTestCasechecks each group's documents againstfilterQuery(groupValue)(:50,:71,:108,:160,:193,:215,:261), built asLongPoint.newRangeQuery("long", groupValue.min, groupValue.max - 1)(TestLongRangeGroupSelector.java:57) andDoublePoint.newRangeQuery("double", groupValue.min, Math.nextDown(groupValue.max))(TestDoubleRangeGroupSelector.java:57). Every upstream factory is aligned:(100, 100, 900)("groups are 100 wide from 100 to 900",:37,:46) and(10, 10, 50)(TestLongRangeFactory.java:26,TestDoubleRangeFactory.java:26). The shorter final range was never tested. - History. The javadoc and the unclamped line came in the same commit,
7c350d22c707(LUCENE-7889); the 2017 JIRA patch already had the line. The PR (apache/lucene-solr#1484) has no review comments, and issue #8940 does not discuss the final range. No CHANGES, MIGRATE, test or comment endorses overlapping ranges.
The devil's-advocate check157 words
The strongest case (the cross-model reviews, the intended-behaviour checker and the adjudication): the labels are opaque equality keys and membership is right; GroupingSearch, GroupDocs, TopGroups and SearchGroup are @lucene.experimental; every upstream test uses an aligned max; and changing a label is a behaviour change for applications that store it.
Why it fails: the factories, LongRange and DoubleRange carry no experimental tag, and experimental status allows API change, not wrong documented values. The javadoc expects an unaligned max and states the shorter final range; Lucene's own tests read the bounds as a range, not a key. The result also matches neither consistent convention of Solr's range faceting: facet.range.hardend=true ends the last range at the end, false extends it and starts the "after" bucket where it ends; Lucene extends the label like false but starts the "above max" bucket at max like true. The reviews rated the intended case weak. Verdict: a defect.
Recommended End the bucket at max, overflow-safe (validated)
Cut the bucket's upper bound at max: in LongRangeFactory with an overflow-safe check (end = reuse.min + width; max if end > max or the sum wrapped), in DoubleRangeFactory with Math.min(reuse.min + width, max). The test and the grouping module pass; for a positive width only that one label changes.
@@ LongRangeFactory#getRange
reuse.min = min + (bucket * width);
- reuse.max = reuse.min + width;
+ long end = reuse.min + width;
+ // the final range ends at max (it "may be up to this width");
+ // end < reuse.min only when reuse.min + width overflows
+ reuse.max = (end < reuse.min || end > max) ? max : end;
@@ DoubleRangeFactory#getRange
reuse.min = min + (bucket * width);
- reuse.max = reuse.min + width;
+ // the final range ends at max (it "may be up to this width")
+ reuse.max = Math.min(reuse.min + width, max);All options and trade-offs4 options · 657 words
One fix was validated in a scratch worktree of 645d4fe14a20 with gradle. The hunt's shorter long form also fixes the test but overflows at the extremes and breaks labels that stock gets right.
In both getRange methods, cut the bucket's upper bound at max. In LongRangeFactory a wrapped reuse.min + width (only possible when the true end is past Long.MAX_VALUE, so past max) also means "cut to max"; for doubles Math.min suffices. 2 files, +6/-2.
@@ LongRangeFactory#getRange
reuse.min = min + (bucket * width);
- reuse.max = reuse.min + width;
+ long end = reuse.min + width;
+ // the final range ends at max (it "may be up to this width");
+ // end < reuse.min only when reuse.min + width overflows
+ reuse.max = (end < reuse.min || end > max) ? max : end;
@@ DoubleRangeFactory#getRange
reuse.min = min + (bucket * width);
- reuse.max = reuse.min + width;
+ // the final range ends at max (it "may be up to this width")
+ reuse.max = Math.min(reuse.min + width, max);Trade-offs
- Behaviour change. For a positive
widthwithout overflow invalue - min, only the label of the last bucket below an unalignedmaxchanges (LongRange(20, 30)becomesLongRange(20, 25); the top-edge wrap below is that same bucket whenmax = Long.MAX_VALUE); membership, counts and every other label are unchanged, because the new upper bound depends only on the bucket's lower bound.LongRangeandDoubleRangeexposeminandmaxas public fields, so applications that store, compare or display old labels see the new value. - Also fixes a stock wrap at the top edge:
LongRangeFactory(0, 10, Long.MAX_VALUE)labelsLong.MAX_VALUE - 3asLongRange(9223372036854775800, -9223372036854775806)on stock andLongRange(9223372036854775800, 9223372036854775807)with the fix. - Cost: one comparison per call; no benchmark was run.
What the checks validated
- Stock + test: 2 of 2 fail with assertions on and off (gradle).
- + fix,
:lucene:grouping:checkwith assertions on: the whole module, 15 suites (14 upstream, including the four range factory and selector suites, + the test), 58 tests, 0 failures;checkGoogleJavaFormat,ecjLintandforbiddenApispass. - + fix,
:lucene:grouping:testwith assertions off: the same 58 tests, 0 failures. - + fix, standalone runner: the test passes 2 of 2 with
-eaand-da. - Side probe (not a test to add): 6 of 6 labels right with the fix, including factories with
max = Long.MAX_VALUEandmin = Long.MIN_VALUE. - Not run: the full test suite, a benchmark.
reuse.max = max - reuse.min < width ? max : reuse.min + width in LongRangeFactory (DoubleRangeFactory as above). The hunt records that it passes the test, the 56 upstream grouping tests and the exhaustive check (the exhaustive log labels its lines only WITH-FIX).
Trade-offs
max - reuse.min overflows when the span from the bucket to max exceeds Long.MAX_VALUE, and then it breaks labels that stock gets right: LongRangeFactory(-1000, 10, Long.MAX_VALUE).getRange(-995) is LongRange(-1000, -990) on stock and LongRange(-1000, 9223372036854775807) with this form; max = Long.MAX_VALUE is a natural "no upper bucket" setting. (Math.min(reuse.min + width, max) for longs fails at the top edge instead; not run.)
What the checks validated
The side probe shows the two wrong labels; the test and the 56 grouping tests pass (the hunt's run on ea57360912ed).
Keep full-width labels and move the >= max boundary to the end of the last bucket, like Solr's hardend=false.
Trade-offs
Contradicts "all longs above this value are grouped into a single range" and changes group membership.
Say in the javadoc that the last bucket below max may overlap the bucket for values >= max.
Trade-offs
Keeps a label that contradicts LongRange's own [min, max) javadoc and the final-range clause.
Applications that turn range-group labels into filters get, for that one bucket, extra documents less than one width above max, all from the next bucket of the same results; until a fix ships, cut the label's max to the factory's max.
Code reading while comparing each group's reported range with a point range query over the same field, then a bounded-exhaustive check of the real factories: 823 of 1,014 small configurations per factory break the javadoc's partition, all with an unaligned max. A public-API JUnit followed.
The method in full169 words
- First signal (hunt unit on range bounds across code paths). While extending a sweep that compares a range as reported by one API with the same range in another to grouping by range (a group's reported
[min, max)versus a point range query over the same field), the finder readgetRangeand saw that the bucket's end is never capped atmax. - Bounded-exhaustive check of the real factories on the stock main jar: min and max in [-6, 6], width 1 to 13, values -20 to 20, 1,014 configurations per factory; 823 violate the javadoc's partition, all with
(max - min) % width != 0, 0 with the fix. - Then a public-API JUnit, independent verifiers, cross-model reviews (reading only) and an adjudication; the hypothesis came from reading, so the finding counts as non-formal.
- An earlier grouping hunt had noted the same observation from code reading and skipped it as "labels only"; a later unit found it again (same root cause and fix). Both are our own findings.
No prior report found (2026-10-26: 79 automated and 52 manual queries over GitHub, the Lucene lists, JIRA and the web; all 411 open PRs' files), refreshed 2026-10-30. A downstream subclass (NSDb) clamps its own uneven bucket but never describes this bug.
Searches5 searches
Judgment call: NSDb's NSDbLongRangeFactory (radicalbit/NSDb#177, 2020) subclasses LongRangeFactory with a different, max-anchored design that clamps its uneven bucket at min; its code, test and PR never say that the upstream factory overlaps the "above max" group, so it is read as tangential. If counted as partial overlap, the bug becomes "known".
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr, all of GitHub) | 2026-10-26 | the scripted prior-report search (24 issue/PR queries) and 18 manual searches, including every bare class name and phrases such as "range grouping", "final range", "overlapping ranges" | 0 hits for every class name; only the feature's own issue #8940 and unrelated issues |
| Code search and open PRs | 2026-10-26 | GitHub code search for LongRangeFactory (146 files, first 100 read); the changed files of all 411 open apache/lucene PRs | copies of the sources, a Go port, the NSDb subclass; no open PR touches the factories, the range selectors or the range classes |
| Commits and history | 2026-10-26 and 2026-10-30 | commit history of both factory files on main, branch_10x, branch_10_6, branch_10_5, branch_9x; CHANGES.txt | only the introducing commit and a reformat (branch_10x and branch_9x also show their branch-retirement commits, #16911 and #13882); no fix (main 716d6f216f52 on 2026-10-30) |
| dev@, java-user@, issues@, commits@ lists; JIRA archive; web | 2026-10-26 | 40 tool and 20 manual list queries, 11 JIRA queries, 8 web searches (one on the javadoc phrase "final range which may be up to this width") | 0 hits on dev@, java-user@, issues@ and in JIRA; commits@ only has commit mails (LUCENE-7889 on master and branch_8x, the LUCENE-9570 reformat, #15167); nothing relevant on the web |
| GitHub refresh | 2026-10-30 | issue search for LongRangeFactory and PR search for RangeFactory in apache/lucene | 0 hits |
Introduced with the feature by 7c350d22c707 (LUCENE-7889, merged 2020-05-11), first released in 8.6.0; only reformatted since. Affected from 8.6.0 to 10.6.0 and on main (executed on 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main; the releases between by unchanged source); not fixed upstream on 2026-10-30.
Versions tested6 rows
7c350d22c707, "LUCENE-7889: Allow grouping on Double/LongValuesSource (#1484)", Alan Woodward (romseygeek), 2020-05-11, PR apache/lucene-solr#1484, issue LUCENE-7889 (now #8940); branch_8x backport 5e62c42c5f30 the same day. The only later change to the files is 2cbf261032dc (LUCENE-9570 reformat, 2021): formatting only, braces around the reuse == null branch and a javadoc rewrap with the text unchanged; the bucket arithmetic is untouched. CHANGES: Lucene 8.6.0, New Features. Not a regression: the javadoc that promises the shorter final range came in the same commit as the code that never implemented it.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails 2 of 2, gradle and standalone, assertions on and off |
| 10.6.0 | affected | the minimal test fails 2 of 2 (release jars, -ea, -da) |
| 10.5.2, 9.12.3 | affected | the minimal test fails 2 of 2 (release jars) |
| 8.6.0, 8.11.4, 9.0.0, 10.0.0 | affected | the follow-up's main() probe fails; its aligned control passes (release jars; 8.6.0 with -ea and -da) |
| 8.5.2 | no API | the classes do not exist (the probe does not compile) |
fix (scratch worktree of 645d4fe14a20) | not affected | the test passes; the grouping module passes |
Independent verifiers, cross-model reviews (reading only; one real, one procedural not-real) and an adjudication: confirmed, high confidence, impact minor. This doc re-ran the minimal test on main and three releases and validated the fix with gradle.
Checks and verdicts346 words
The cross-model reviews used an independent reviewer from another model family (OpenAI GPT-5.6) that could only read code; every executed check comes from the hunt's own runs, mostly on main ea57360912ed, where lucene/grouping is identical to 645d4fe14a20.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | JUnit fails 3 of 3 (-ea, -da); a main() form fails on 10.5.2, 9.12.3 and 8.6.0; exhaustive check; fix passes 56 grouping tests. |
| review, refute lens | not real, low, procedural | Could not run code and called its verdict "not a substantive refutation"; root cause agrees, intended case weak; a separate run re-ran the repro (fails). Noted that the hunt's long fix can overflow. |
| Independent reproducer | real | Own tests (partition sweep and an end-to-end drill-down) fail 6 of 6 on main (-ea, -da), 10.5.2 and 9.12.3; pass with the clamp. |
| Cross-model review, intended lens | real, medium | Reading only; "labels are opaque keys" rated weak; fix fixes it. |
| Devil's advocate | real, minor | No text endorses overlapping buckets; age-style factory (18, 10, 65) variant fails on main, 10.5.2 and 9.12.3. |
| adjudication | confirmed, high; impact minor | Re-ran the finder's test on main (-ea, -da) and the independent reproduction's tests on main (-ea, -da) and 10.5.2; the independent reproduction's pass with the fix. |
| this doc | as above | Minimal test on main (gradle and standalone, -ea, -da), 10.6.0, 10.5.2, 9.12.3; the fix with gradle; showed by execution that the hunt's long fix overflows at the extremes (the refute review had warned of it). |
| Fact checks of this page (an independent one, a read-only cross-model one, OpenAI GPT-5.6, and a diagram check) | substantively correct; wording fixed | The independent check re-ran the gradle repro (assertions on and off), the recommended fix with the whole grouping module (58 tests) and its static checks, the test on the 10.6.0 jars and the overflow probe. Their wording fixes are applied, and the diagram now shows the recommended fix and the releases actually run. |
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 570 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,441 words
- Registry title
- Grouping LongRangeFactory and DoubleRangeFactory give the last range below max the full width, so its reported group bounds run past max and overlap the range for values >= max
- Area
- grouping / range group selectors (LongRangeFactory and DoubleRangeFactory, used by LongRangeGroupSelector and DoubleRangeGroupSelector)
- Priority
- Low, rank 566: Grouping LongRangeFactory and DoubleRangeFactory label the last bucket below max with a range that runs past max while group membership stays correct, a wrong-label defect in a rare grouping API.
- Impact class
- minor
- Impact note
- Applications that group by numeric ranges with LongRangeGroupSelector or DoubleRangeGroupSelector (through GroupingSearch or the grouping collectors) and a LongRangeFactory or DoubleRangeFactory whose max is not min + k * width get, on every search in 8.6.0 through 10.6.0 and on main (-ea and -da, any index size), a group value for the last bucket below max that ends a full width past max and overlaps the 'values >= max' group, for example LongRange(20, 30) next to LongRange(25, MAX) for LongRangeFactory(10, 10, 25); which documents land in which group, the counts and the group order are correct, so only the labels and anything built from them are wrong, such as a bucket heading or a drill-down range query that also returns the other group's documents; factories with an aligned max, the only shape in Lucene's own tests, are not affected.
- Summary
- You group documents with new LongRangeFactory(10, 10, 25), meaning buckets 10 wide starting at 10 and everything at or above 25 in one bucket, and expect what the javadoc says: [10, 20), a shorter final bucket [20, 25), and [25, MAX). Instead the value 22 is labelled LongRange(20, 30) while 27 is labelled LongRange(25, MAX), so the two reported ranges overlap; each group holds the right documents, but a range query built from the label [20, 30), the way Lucene's own selector tests rebuild a group, also returns the document with value 27 from the other group. DoubleRangeFactory behaves the same, because both getRange methods set the bucket's upper bound to its lower bound + width and never cap it at max.
- Introduced
- 7c350d22c7070acf362fb4f477e6797f2711c910 (LUCENE-7889 'Allow grouping on Double/LongValuesSource', PR apache/lucene-solr#1484 by romseygeek, migrated issue apache/lucene#8940; branch_8x backport 5e62c42c5f304eee7b5695f97f02a2aea7d00366) [read: git log -S 'reuse.max = reuse.min + width' and -S 'final range which may be up' in the full-history clone return only this commit; the 2017 LUCENE-7889 JIRA patch already had the same getRange body; the only later change to the lines is 2cbf261032dc (LUCENE-9570 reformat, 2021-01-05, braces only)]. Born with the feature, not a regression: the javadoc that promises a shorter final range came in the same commit, on top of code that never implemented it (2020-05-11)
- First affected version
- 8.6.0 [exec] (the first release with the range factories; 8.5.2 has no such API [exec: the probe does not compile])
- Affected versions
- every release that has the classes: 8.6.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.6.0 and main ea57360912ed [exec, 10.6.0 release jars (-ea, -da): the bug doc's minimal test fails 2 of 2; history main() probe RangeFactoryHistoryProbe with an aligned-max control that passes: 8.6.0 (-ea, -da), 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, main (-ea, -da); the finder's JUnit fails 3 of 3 on main (-ea, -da); the independent reproduction's JUnits fail 6 of 6 on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check's variant fails 3 of 5 (2 controls pass) on main (-ea, -da), 10.5.2 and 9.12.3; follow-up re-runs 2026-10-26: finder JUnit main -ea and -da 3 of 3, the independent reproduction main -da and 10.5.2 -da 6 of 6] [read: the getRange lines are identical at tags 8.6.0, 8.8.1, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 apart from the reformat; releases between the executed ones were not run one by one]. Not fixed upstream: main cf398bfb142c (2026-10-26), branch_10x, branch_10_6, branch_10_5 and branch_9x carry only the two commits above [read, GitHub commits API]. All runs on one aarch64 Linux host with JDK 25.0.3
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record, verdict new): a scripted prior-report search with 79 queries (24 GitHub issue/PR, 3 commit, 2 downstream, 40 over the dev@, java-user@, issues@ and commits@ lists, 4 JIRA, 6 web; 287 distinct hits, none about this bug), plus 52 logged manual queries and reads: 18 GitHub issue/PR searches (every bare class name: 0 hits in apache/lucene, apache/lucene-solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr and all of GitHub; the phrase searches found only the feature issue #8940 and unrelated issues), a GitHub code search for LongRangeFactory (146 files, first 100 read), the changed files of all 411 open apache/lucene PRs (none touches the factories, the range selectors or LongRange/DoubleRange), the upstream commit history of both files on main cf398bfb142c, branch_10x, branch_10_6, branch_10_5 and branch_9x (no fix), 5 list and 7 JIRA queries (0 hits for every class name), 8 web searches. Read, not reports: #8940 (LUCENE-7889, the feature; 6 comments, none on the final range), apache/lucene-solr#1484 (no comments or reviews), the 2017 LUCENE-7889 patch (same getRange body), 2cbf261032dc (reformat). Downstream code, not a report: the NSDb project's NSDbLongRangeFactory (radicalbit/NSDb#177, 2020) subclasses LongRangeFactory with a different max-anchored design that clamps the uneven bucket at min and throws outside [min, max], and never says that the upstream factory overlaps the 'above max' group; a Go port (Gocene) copies the code unchanged. Gaps: the DoubleRangeFactory code search was not run (rate limit); issue search does not index PR diffs (merged history shows no fix)
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test through gradle in a clean scratch worktree (assertions on and off, stock and with the fix, plus the grouping module and its static checks) and through the standalone runner (-ea,-da); the 10.6.0 (-ea,-da), 10.5.2 and 9.12.3 release jars (Maven Central, SHA-1 checked). The hunt, verifiers, judge and follow-up ran on mainea57360912ed, the follow-up'smain()probe also on the 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 jars;lucene/groupingis identical atea57360912ed,a9779ef3c193and645d4fe14a20.
- Finding record
lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRangeFactory.java:30-34 (at ea57360912ed) (constructor javadoc: '@param width a standard width; all ranges between {@code min} and {@code max} are this wide, with the exception of the final range which may be up to this width. Ranges are inclusive at the lower end, and exclusive at the upper end.'; '@param max ... all longs above this value are grouped into a single range'), :48-66 (getRange; :52-55 below min, :57-60 values >= max get [max, Long.MAX_VALUE), :62-64 reuse.max = reuse.min + width, never capped)lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeFactory.java:30-34 (at ea57360912ed) (same javadoc), :48-66 (same getRange structure; :54 below-min label Double.MIN_VALUE is a separate candidate)lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRange.java:22-29 (at ea57360912ed) ('Represents a contiguous range of long values, with an inclusive minimum and exclusive maximum'; 'The exclusive maximum value of this range') and DoubleRange.java:23-33 (same for doubles)lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRangeGroupSelector.java:66-77 (at ea57360912ed) (advanceTo: one getRange call per document; per the security record) and GroupingSearch.java:44 (@lucene.experimental; the factories, LongRange and DoubleRange carry no such tag)lucene/grouping/src/test/org/apache/lucene/search/grouping/TestLongRangeFactory.java:26 (at ea57360912ed) and TestDoubleRangeFactory.java:26 (factory (10, 10, 50), aligned); TestLongRangeGroupSelector.java:37 ('groups are 100 wide from 100 to 900'), :46 (factory (100, 100, 900)), :50-58 (filterQuery: LongPoint.newRangeQuery(min, max - 1) at :57); TestDoubleRangeGroupSelector.java:46, :50-57 (DoublePoint.newRangeQuery(min, Math.nextDown(max)) at :57); BaseGroupSelectorTestCase.java:50, :71, :108, :160, :193, :215, :261 (each group's documents are checked against filterQuery(groupValue))
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRangeFactory.java:30-40, :48-66; DoubleRangeFactory.java:30-40, :48-66a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRange.java:22-29, :42-53; DoubleRange.java:22-31, :44-55a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRangeGroupSelector.java:66-77; GroupingSearch.java:85; package-info.java:29-31
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRangeFactory.java:31-34; DoubleRangeFactory.java:31-34; LongRange.java:22-29; DoubleRange.java:22-31a Lucene main checkout (645d4fe14a20) lucene/grouping/src/test/org/apache/lucene/search/grouping/BaseGroupSelectorTestCase.java:50, :71, :108, :160, :193, :215, :261; TestLongRangeGroupSelector.java:37, :46, :57; TestDoubleRangeGroupSelector.java:37, :46, :57; TestLongRangeFactory.java:26; TestDoubleRangeFactory.java:26a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:44; GroupDocs.java:33; TopGroups.java:38; SearchGroup.java:38 (@lucene.experimental)
- History
a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:5689, :5727-5728
- TestRangeFactoryFinalRange.java (lucene/grouping, 2 one-line tests):
repro/LFV-407/src/org/apache/lucene/search/grouping/TestRangeFactoryFinalRange.java(31 lines)
Made in parallel with this doc from the same example; every value drawn comes from the code at 645d4fe14a20 or the diagram's executed probe (stock, and with the recommended fix, assertions on and off).
assets/LFV-407/bug.png(570 KB)
![Number line for LongRangeFactory(10, 10, 25) with documents at 12, 22, 27 and 40: the expected buckets [10, 20), [20, 25) and [25, MAX), and the label LongRange(20, 30) that Lucene reports for 22, which overlaps LongRange(25, MAX) on 25 to 29, so the drill-down price:[20 TO 29] also returns 27.](../assets/LFV-407/bug.png)