All findings · Lucene finding · LFV-407 · grouping-range-factory-final-range-not-clamped-to-max

Range grouping labels the last bucket below max a full width wide, so it overlaps the bucket for values >= max

Range grouping with new LongRangeFactory(10, 10, 25) labels the value 22 LongRange(20, 30), although the javadoc promises a shorter final range [20, 25) and 25 to 29 belong to LongRange(25, MAX). Groups hold the right documents, but a range query built from that label also returns the next group's.

Summary

Priority
Low, rank 566Grouping 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
minor
Since
8.6.0 (LUCENE-7889, 2020)introduced 2020-05-11 · about 6 years
Affected
8.6.0 to 10.6.0 and main
Status
documented
Found by
code reading, then a bounded-exhaustive check of the real factories

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: grouping by numeric ranges

  1. GroupingSearch groups hits by one value per document. LongRangeGroupSelector computes that value with LongRangeFactory.getRange(value); DoubleRangeGroupSelector does the same for doubles.
  2. new LongRangeFactory(min, width, max) makes one bucket below min, buckets of width from min up to max (the last one may be shorter), and one bucket for everything at or above max. Each bucket is a LongRange from an inclusive minimum to an exclusive maximum.
  3. 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.
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.
The buckets of 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.

The finding, step by step

  1. Group four documents with prices 12, 22, 27 and 40 by price, with LongRangeGroupSelector and new LongRangeFactory(10, 10, 25): buckets 10 wide starting at 10, and one bucket for everything at or above 25.
  2. 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.
  3. You get three groups with the right members, {12}, {22} and {27, 40}, but the middle one is labelled LongRange(20, 30): getRange ends every bucket at its start + 10 and never cuts it at max 25, so it overlaps LongRange(25, MAX) on 25 to 29.
  4. 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.
  5. DoubleRangeFactory does the same. Only a max that is not min + k x width triggers it (ages 18 to 65 by 10 give LongRange(58, 68) next to LongRange(65, MAX)), in every release since 8.6.0.

Who is affected

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.

The mechanism (main 645d4fe14a20)

  1. LongRangeFactory.getRange computes the bucket and never caps its end (LongRangeFactory.java:62-64; DoubleRangeFactory.java:62-64 is the same with Math.floor):
   long bucket = (value - min) / width;
   reuse.min = min + (bucket * width);
   reuse.max = reuse.min + width;   // never capped at max
  1. Values at or above max get [max, Long.MAX_VALUE) (:57-61; Double.MAX_VALUE for doubles).
  2. 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 aligned max (k + width == max) this overlap does not occur (for doubles: integral values, as tested; decimal widths have a separate rounding issue, LFV-412).
  3. Membership is decided by label equality, so it stays right: LongRangeGroupSelector calls getRange once per document (LongRangeGroupSelector.java:72) and keeps a document in the second pass if its label is one of the top groups (:76); LongRange and DoubleRange compare both bounds (LongRange.java:42-53, DoubleRange.java:44-55). Only the reported bounds of that one bucket are wrong.
  4. The constructors accept any (min, width, max) without validation (LongRangeFactory.java:36-40, DoubleRangeFactory.java:36-40). This page assumes a positive width, min < max, finite double parameters and no long overflow in value - min (a width of 0, for example, is a long division by zero).

The numbers

  • LongRangeFactory(10, 10, 25): 20 to 24 get LongRange(20, 30), 25 to 40 get LongRange(25, MAX); DoubleRangeFactory(10, 10, 25) gives DoubleRange(20.0, 30.0) and DoubleRange(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 label LongRange(20, 30) returns 22 and 27. With the recommended fix the label is LongRange(20, 25) and the drill-down price:[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.

Not part of this bug

  • DoubleRangeFactory labels the below-min bucket with Double.MIN_VALUE as its lower bound (DoubleRangeFactory.java:54; LFV-411).
  • With a decimal width, DoubleRangeFactory rounds the bucket choice and the bounds separately, so a returned range can miss its value: DoubleRangeFactory(0, 0.1, 10).getRange(0.6) is DoubleRange(0.5, 0.6) (LFV-412). Capping at max does not change that.
  • LongRangeFactory computes value - min without an overflow check, and [max, Long.MAX_VALUE) cannot contain Long.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 max is 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.

Minimal repro

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.

TestRangeFactoryFinalRange.java (lucene/grouping, 2 one-line tests)31 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.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));
  }
}
Command
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) and DoubleRange(20.0, 25.0): the final range below max 25 ends at 25.
Observed
LongRange(20, 30) and DoubleRange(20.0, 30.0), overlapping [25, MAX): both tests fail, assertions on and off.

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

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).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0, 10.5.2 and 9.12.3 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code is pure arithmetic)
Command (environment)
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

Observed output

# 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)

Why it looks unintended

  • Javadoc: ranges are width wide "with the exception of the final range which may be up to this width".
  • LongRange is documented as [min, max); LongRange(20, 30) does not hold 25 to 29, which go to LongRange(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 (and DoubleRangeFactory.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 unaligned max, 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-31 the same).
  • Lucene's tests use the bounds as a range. BaseGroupSelectorTestCase checks each group's documents against filterQuery(groupValue) (:50, :71, :108, :160, :193, :215, :261), built as LongPoint.newRangeQuery("long", groupValue.min, groupValue.max - 1) (TestLongRangeGroupSelector.java:57) and DoublePoint.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.

Suggested fix

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.

End the bucket at max, overflow-safe (validated) (recommended)

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 width without overflow in value - min, only the label of the last bucket below an unaligned max changes (LongRange(20, 30) becomes LongRange(20, 25); the top-edge wrap below is that same bucket when max = Long.MAX_VALUE); membership, counts and every other label are unchanged, because the new upper bound depends only on the bucket's lower bound. LongRange and DoubleRange expose min and max as 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) labels Long.MAX_VALUE - 3 as LongRange(9223372036854775800, -9223372036854775806) on stock and LongRange(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:check with assertions on: the whole module, 15 suites (14 upstream, including the four range factory and selector suites, + the test), 58 tests, 0 failures; checkGoogleJavaFormat, ecjLint and forbiddenApis pass.
  • + fix, :lucene:grouping:test with assertions off: the same 58 tests, 0 failures.
  • + fix, standalone runner: the test passes 2 of 2 with -ea and -da.
  • Side probe (not a test to add): 6 of 6 labels right with the fix, including factories with max = Long.MAX_VALUE and min = Long.MIN_VALUE.
  • Not run: the full test suite, a benchmark.

The hunt's shorter long form (not recommended) (validated by the checks)

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).

Start the "above max" bucket where the last range ends (not recommended; not run)

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.

Document the overlap (not recommended)

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.

Downstream note

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.

How it was found

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 read getRange and saw that the bucket's end is never capped at max.
  • 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.

Prior reports, history and checks

Prior reports

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".

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr, all of GitHub)2026-10-26the 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 PRs2026-10-26GitHub code search for LongRangeFactory (146 files, first 100 read); the changed files of all 411 open apache/lucene PRscopies of the sources, a Go port, the NSDb subclass; no open PR touches the factories, the range selectors or the range classes
Commits and history2026-10-26 and 2026-10-30commit history of both factory files on main, branch_10x, branch_10_6, branch_10_5, branch_9x; CHANGES.txtonly 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; web2026-10-2640 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 refresh2026-10-30issue search for LongRangeFactory and PR search for RangeFactory in apache/lucene0 hits

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails 2 of 2, gradle and standalone, assertions on and off
10.6.0 affectedthe minimal test fails 2 of 2 (release jars, -ea, -da)
10.5.2, 9.12.3 affectedthe minimal test fails 2 of 2 (release jars)
8.6.0, 8.11.4, 9.0.0, 10.0.0 affectedthe follow-up's main() probe fails; its aligned control passes (release jars; 8.6.0 with -ea and -da)
8.5.2 no APIthe classes do not exist (the probe does not compile)
fix (scratch worktree of 645d4fe14a20) not affectedthe test passes; the grouping module passes

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit 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, proceduralCould 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 realOwn 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, mediumReading only; "labels are opaque keys" rated weak; fix fixes it.
Devil's advocate real, minorNo 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 minorRe-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 aboveMinimal 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 fixedThe 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.

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

Finding record

LFV-407, snapshot of 2026-11-06

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 main ea57360912ed, the follow-up's main() 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/grouping is identical at ea57360912ed, a9779ef3c193 and 645d4fe14a20.

Lucene and upstream sources

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-66
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRange.java:22-29, :42-53; DoubleRange.java:22-31, :44-55
  • a 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-31
  • a 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:26
  • a 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

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

Repro files

About the diagrams

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).

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.