All findings · Lucene finding · LFV-044 · encoded-domain-dv-range-scan

Doc-values range queries with a skip index return wrong hits when a segment's values span more than Long.MAX_VALUE

With the opt-in doc-values skip index, doc-values range queries lose hits when a segment's values span more than Long.MAX_VALUE, as latitudes or 64-bit hashes do: [45, 90] over 2,048 latitudes counts 0, not 512. A 10.6.0 regression (released 2026-10-29) that also hits 10.5.2-written indexes; open PR #16896 fixes it.

Summary

Priority
High, rank 3A 10.6.0 regression that returns badly wrong range hits on ordinary signed-double or hash data with the opt-in skip index, and the outside fix is still unmerged.
Impact
silent wrong results
Since
10.6.0 (released 2026-10-29): #16160 (2026-07-02) and #16285 (2026-07-13)introduced 2026-07-02
Affected
main and branch_10_6 (10.6.0, released 2026-10-29); branch_10x and branch_11x carry the same code (by reading); 10.0.0 to 10.5.2 are correct
Status
documented
Found by
code reading in a SIMD-equivalence survey; Z3 then characterised it

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

Since 10.6.0 development, the codec's bulk range check for numeric doc values compares the stored form v - min against shifted query bounds instead of decoding each value. That breaks when the stored form has wrapped to a negative long, which happens when a segment's values span more than Long.MAX_VALUE (with more than 256 distinct values and no block encoding).

Background: how numeric doc values are stored and range-checked

  1. Doc values keep one number per document, column-wise. The default codec stores each value as v - min (divided by a common divisor, the gcd, when there is one) with just enough bits per value; reading a value adds min back.
  2. Doubles are indexed as sortable longs (NumericUtils.doubleToSortableLong): positive doubles become large positive longs and negative doubles large negative longs. A segment with values of -2 or less and +2 or more spans more than Long.MAX_VALUE; so do random 64-bit hashes.
  3. With the opt-in skip index (indexedField), a doc-values range query first checks each block's min and max: blocks fully inside the range match, blocks outside are skipped, and the rest are checked document by document with the codec's bulk rangeIntoBitSet.
Diagram of the minimal repro: 2,048 latitudes in a skip-indexed doc-values field and the range query [45, 90]; expected 512 hits, actual 0.
The minimal repro as a picture: the 2,048 latitudes, their stored values, the query bounds after the transform, and expected versus actual hits. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 2,048 latitudes evenly spaced from -89.96 to +89.96 as NumericDocValuesField.indexedField("lat", NumericUtils.doubleToSortableLong(lat)), the field type with a doc-values skip index; default codec, one segment. 512 of them lie in [45, 90].
  2. Run NumericDocValuesField.newSlowRangeQuery("lat", sortable(45), sortable(90)). You expect count = 512 and 10 hits from search(q, 10): 10.5.2 gives that, and so does the same field without a skip index.
  3. Instead count = 0 and search(q, 10) returns 0 hits, with no error. On 100,000 random latitudes [45, 90] gives 0 of 25,157; on 64-bit hashes [0, Long.MAX_VALUE] gives 2 of 49,931.
  4. Why: the codec stores each value as v - min. Here the values span about 9.27e18, more than Long.MAX_VALUE (9.22e18), so the stored value of every latitude above about +0.05 degrees wraps to a negative long (1,023 of 2,048). Reading one value adds min back and is right.
  5. Since #16160 the bulk range check behind skip-indexed queries compares stored values directly: sortable(45) - min overflows, both bounds saturate to Long.MAX_VALUE, and the range [Long.MAX_VALUE, Long.MAX_VALUE] matches nothing. 10.5.2 decoded each value first.

Who is affected

Users of the opt-in doc-values skip index (indexedField or DocValuesSkipIndexType.RANGE) whose range-queried numeric field has a segment with more than 256 distinct values spanning more than Long.MAX_VALUE: signed doubles past +/-2 (latitudes, longitudes, signed scores) or full-range 64-bit hashes and ids. Non-negative longs and epoch timestamps of realistic dates span far less.

Mechanism, terms and numbers880 words

The default codec stores a numeric doc value as (v - min) / gcd. When one segment's values span more than Long.MAX_VALUE and the segment is neither table- nor block-encoded, it uses gcd 1 and 64 bits per value, and the stored v - min of the upper values wraps to negative longs; reading one value wraps back and is right. #16160 and #16285 (10.6.0 development) changed the bulk rangeIntoBitSet of the Lucene90 doc-values producer to compare stored values with signed comparisons against shifted query bounds, saturating a shift above Long.MAX_VALUE to Long.MAX_VALUE. Wrapped values then never match, and Long.MAX_VALUE can itself be a stored value. With the opt-in doc-values skip index (NumericDocValuesField.indexedField, SortedNumericDocValuesField.indexedField), newSlowRangeQuery routes every block the skip index cannot decide through that method, so count, search(q, n) and FILTER clauses return wrong hits: 2,048 evenly spaced latitudes, [45, 90], count 0 instead of 512; 100,000 random latitudes, 0 instead of 25,157; 100,000 random 64-bit hashes, [0, Long.MAX_VALUE], 2 instead of 49,931; hand-picked values add hits. No error, and CheckIndex is clean. 10.5.2 decoded before comparing and is right; an index written by 10.5.2 returns 0 once opened with the 10.6.0 RC1 commit. kkewwei reported and fixed it publicly in open PR #16896 (2026-10-23); costin, who wrote the two introducing PRs, commented on it and suggested gating on the encoding. The study's three-part fix does that and also removes the Long.MAX_VALUE sentinel for an overflowing lower bound; it passes the test and 14 doc-values suites.

Terms

  • Stored value: what the codec writes per document, (v - min) / gcd packed in a fixed number of bits per value (Lucene90DocValuesConsumer.java:553). With 64 bits per value and gcd 1 it is a plain 64-bit subtraction that can wrap.
  • Encoded-domain range check: since #16160, rangeIntoBitSet maps the query bounds into the stored domain once (transformGcdBounds, Lucene90DocValuesProducer.java:550-563) and compares stored values directly, which lets a SIMD kernel run without decoding.
  • Skip index: per-block min/max values (DocValuesSkipper), enabled by NumericDocValuesField.indexedField, SortedNumericDocValuesField.indexedField or DocValuesSkipIndexType.RANGE.

Mechanism (file:line at 645d4fe14a20)

  1. Writer. Lucene90DocValuesConsumer.java:417-422 resets gcd to 1 for values outside [MIN/2, MAX/2]; :511 uses DirectWriter.unsignedBitsRequired((max - min) / gcd), which is 64 when max - min overflows; :553 writes (v - min) / gcd. Needs more than 256 distinct values (:434-435, else table encoding) and no block split (:505-507).
  2. Per-value read is right. mul * values.get(doc) + delta (Lucene90DocValuesProducer.java:1040) wraps back to v.
  3. Bulk read. The dense gcd/delta override (:1066-1076) calls rangeGcdDeltaIntoBitSet (:569-583) -> transformGcdBounds (:550-563): saturatingShiftLower/Upper (:585-616) return Long.MAX_VALUE when the shifted bound would be above it (needs delta < 0, as with latitudes) and Long.MIN_VALUE when it would be below Long.MIN_VALUE (:598, :614), the lower bound is clamped to 0, and the kernels compare signed (DefaultDocValuesRangeSupport.java:39-41; PanamaDocValuesRangeSupport.java:57, 106-107). The helpers' javadoc: "Stored values are non-negative" (:588-589).
  4. Both directions. A wrapped (negative) stored value never passes the lower bound (>= 0): missing hits. An overflowing lower bound becomes Long.MAX_VALUE (or ceilDiv(Long.MAX_VALUE, gcd)), and a document stored exactly there matches: extra hits.
  5. Sorted numeric. hasGcdEncoding (:2019-2023) sends dense (:2095-2105, SIMD :2116) and sparse (:2226-2240) sorted-numeric fields to the same transform.
  6. Queries. With a skipper, DocValuesRangeIterator.forRange builds a bulk iterator (DocValuesRangeIterator.java:49-60) whose MAYBE blocks call rangeIntoBitSet (:355-367, :447-453, sorted numeric :478-484). count collects through it at any size, search(q, n) from maxDoc >= 2048 (ConstantScoreScorerSupplier.java:81-92); nextDoc() iteration of the same query is right.

The minimal repro in numbers (executed probe on main): min = sortable(-89.956), max = sortable(+89.956), span about 9.27e18; 64 bits per value, gcd 1; 1,023 stored values are negative, including all 512 in [45, 90]; the skip index has one MAYBE block; transformGcdBounds returns [Long.MAX_VALUE, Long.MAX_VALUE]; no stored value equals it; 0 hits.

Variants (executed). In the tested latitude grids count is first wrong at 257 distinct values (256 or fewer use table encoding) and search(q, 10) first loses its hits at 2,048 docs per segment; at 5,000 docs count is 904 of 1,247 because the second 4,096-doc skip block lies fully inside [45, 90], while search(q, 10) still returns 10 hits and only count and totalHits are wrong. Values {-10, MAX - 10, MAX, 3000, ...} with [MAX - 5, MAX - 1] count 1 instead of 0. SortedNumericDocValuesField is affected single-valued (dense) and multi-valued (dense and sparse). Not affected: sparse single-valued numeric fields (per-doc default), and so sparse single-valued sorted numeric, which the producer wraps as a singleton of numeric values and the query unwraps (Lucene90DocValuesProducer.java:2002-2003, SortedNumericDocValuesRangeQuery.java:138; by reading); table- or block-encoded fields; spans below Long.MAX_VALUE; fields without a skip index at query level (direct rangeIntoBitSet callers are still wrong). At a span of exactly Long.MAX_VALUE only direct rangeIntoBitSet calls go wrong: the top stored value equals the saturated lower bound, so a range above the segment's max sets one spurious bit (study tests testDenseNumericSentinelApiOnly and testDenseNumericGcdSentinel), while the skip index prunes the block at query level.

Exposure and likelihood252 words
  • Default or opt-in. Default codec; the skip index is opt-in. LongField, DoubleField and plain NumericDocValuesField do not enable it. Direct callers of the public NumericDocValues#rangeIntoBitSet get wrong bits even without a skip index (executed); SortedNumericDocValues#rangeIntoBitSet runs the same transform with or without one (by reading).
  • Effect. Silent wrong results, mostly missing hits (0 of 512, 0 of 25,157, 2 of 49,931), occasionally extra hits on hand-picked values. count, search(q, n) and FILTER clauses are all affected. No exception; the index is not corrupt, so CheckIndex cannot see it.
  • Data shape. For a symmetric range +/-x of sortable doubles the span exceeds Long.MAX_VALUE exactly when x >= 2; ordinary latitudes and longitudes qualify. Float fields (sortable ints) cannot. Non-negative longs never span more than Long.MAX_VALUE, and epoch timestamps of realistic dates span far less (epoch nanoseconds would need values more than about 292 years apart); nothing in the code exempts timestamps.
  • Scale. In the tested latitude grids, count was first wrong at 257 distinct values per segment and search(q, 10) first lost its hits at 2,048 documents; a segment also needs the wide span, the 64-bit gcd/delta encoding, a dense field if single-valued, and a skip block only partly inside the range.
  • Existing indexes. The defect is in the reader: an index written by 10.5.2 counts 0 instead of 25,157 when opened with the 10.6.0 RC1 commit, with no reindex.
  • Releases. 10.6.0 was released on 2026-10-29 from RC1, which has the bug. Priority high because the released 10.6.0 has it.

Minimal repro

One JUnit test method in lucene/core (package org.apache.lucene.search) that uses the public indexing and search API and Lucene's test framework: 2,048 evenly spaced latitudes, default codec, one segment, one range query. Deterministic, one thread; it fails the same way with assertions on and off and passes on 10.5.2.

TestSkipIndexedDocValuesRangeWideSpan.java (lucene/core, one test)73 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;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.NumericUtils;

/**
 * A doc-values range query on a skip-indexed field must match every document whose value is in the
 * range, also when the values of one segment span more than {@link Long#MAX_VALUE}. Signed doubles
 * stored as {@link NumericUtils#doubleToSortableLong} sortable longs do that as soon as they reach
 * past +/-2 on both sides of zero, for example latitudes.
 */
public class TestSkipIndexedDocValuesRangeWideSpan extends LuceneTestCase {

  public void testLatitudeRangeQuery() throws Exception {
    // 2048 latitudes evenly spaced from pole to pole (exact binary fractions): a quarter of them,
    // 512, lie in [45, 90]. 2048 docs is the smallest segment for which search(q, 10) uses the
    // dense window-by-window bulk scorer; count(q) is already wrong from 257 distinct values on.
    int numDocs = 2048;
    int expected = 0;
    try (Directory dir = newDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (int i = 0; i < numDocs; i++) {
          double lat = -90 + (i + 0.5) * (180.0 / numDocs);
          if (lat >= 45 && lat <= 90) {
            expected++;
          }
          Document doc = new Document();
          doc.add(
              NumericDocValuesField.indexedField("lat", NumericUtils.doubleToSortableLong(lat)));
          w.addDocument(doc);
        }
        w.forceMerge(1);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Query query =
            NumericDocValuesField.newSlowRangeQuery(
                "lat",
                NumericUtils.doubleToSortableLong(45),
                NumericUtils.doubleToSortableLong(90));
        assertEquals(512, expected);
        int count = searcher.count(query);
        int hits = searcher.search(query, 10).scoreDocs.length;
        assertEquals("count(q); search(q, 10) returned " + hits + " hits", expected, count);
        assertEquals("search(q, 10) hits", 10, hits);
      }
    }
  }
}
Command
cp $REPRO/TestSkipIndexedDocValuesRangeWideSpan.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestSkipIndexedDocValuesRangeWideSpan -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
Expected
count(q) = 512 and search(q, 10) returns 10 hits, as on 10.5.2 and on the same data without a skip index.
Observed
count(q) = 0 and search(q, 10) returns 0 hits: AssertionError: count(q); search(q, 10) returned 0 hits expected:<512> but was:<0>.

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

More files, environment, full output209 words

The data, codec and query are fixed; the seed only picks the test framework's random directory, locale and settings, which the test does not depend on (seeds 1 and 2 and 10 iterations also fail). It fails with the Panama and the scalar vector provider, and compiles unchanged against 10.5.2, where it passes. 2,048 documents is the smallest segment for which search(q, 10) goes wrong too; count alone is wrong from 257 distinct values.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also branch_10_6 4ae946e5a9b (the 10.6.0 RC1 commit; jars built from it); passes on 10.5.2
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the root cause is Java bound arithmetic upstream of vectorization, and it fails with both the Panama and the scalar vector provider)
Command (environment)
cp $REPRO/TestSkipIndexedDocValuesRangeWideSpan.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestSkipIndexedDocValuesRangeWideSpan -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false

Observed output

TestSkipIndexedDocValuesRangeWideSpan > testLatitudeRangeQuery FAILED
    java.lang.AssertionError: count(q); search(q, 10) returned 0 hits expected:<512> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:65FF88D8F9C7AA0]:0)
        ...
        at org.apache.lucene.search.TestSkipIndexedDocValuesRangeWideSpan.testLatitudeRangeQuery(TestSkipIndexedDocValuesRangeWideSpan.java:68)
:lucene:core:test (FAILURE): 1 test, 1  failure

# the same with -Ptests.asserts=false; standalone runner on main and on the 10.6.0 RC1 commit with -ea and -da
# 10.5.2: 1 test, 0 failures (-ea, -da)
# with the recommended fix: :lucene:core:test (SUCCESS): 1 test

Why it looks unintended

  • newSlowRangeQuery "matches all documents whose value is between lowerValue and upperValue included", and indexedField takes any 64-bit long.
  • On a 100k-latitude index count is 0 while iterating the query's matches finds 25,062; 10.5.2 and the field without a skip index are right.
  • costin, who wrote the change, wrote on #16896: "Stored values only go negative when bitsPerValue == 64".
The full argument173 words
  • Contracts. NumericDocValuesField.java:91-92 (newSlowRangeQuery), :51-58 (indexedField, "the specified 64-bit long value"); NumericDocValues.java:113-140 (rangeIntoBitSet: "the doc IDs in [fromDoc, toDoc) whose values are in [minValue, maxValue]", default per-doc advanceExact/longValue); IndexSearcher.java:491 (count).
  • Regression. 10.5.2 runs the same skip-index query path but decodes first ("Per-doc evaluation for gcd/delta encoded fields", Lucene90DocValuesProducer.java:966-972 at 10.5.2) and passes.
  • An unstated premise that fails at 64 bits. The writer lines that produce wrapped 64-bit stored values are unchanged in 10.0.0, 10.5.2, RC1 and main; the gcd reset dates from LUCENE-4936 (2013). The helpers assume "Stored values are non-negative" (Lucene90DocValuesProducer.java:588-589).
  • The authors treat it as a bug. kkewwei filed #16896 as a fix; costin commented on it on 2026-10-28: "Stored values only go negative when bitsPerValue == 64 so keep the saturating helpers for narrower fields and use the decoded path only for 64-bit ones."
  • Why tests missed it. Existing tests use small positive deltas or values from [0, domain]; 260 nearby tests pass on unpatched main.
The devil's-advocate check136 words

Strongest case: the helper javadoc states its assumption ("Stored values are non-negative"), and in the #16160 review costin wrote that with the saturating helpers "the per-doc decoded fallback is provably unreachable, so I removed it" (2026-06-15, answering salvatore-campagna's question about open bounds). So spans over Long.MAX_VALUE could be read as knowingly outside the optimisation's design; the skip index is opt-in and the code new in 10.6.0.

Why it fails: that proof covered bound overflow under an unstated premise, that stored values are non-negative, which fails for 64-bit packed values. No javadoc limits users to spans of Long.MAX_VALUE, the writer produces such segments for ordinary latitudes, the same query was right in 10.5.2, and the introducing author suggested an encoding-based gate for the fix in a comment on #16896. Opt-in lowers the priority, not the bug.

Suggested fix

Recommended Decode for 64-bit fields, and no Long.MAX_VALUE sentinel for an empty lower bound

For fields stored with 64 bits per value, compare decoded values as 10.5.2 did (dense numeric and sorted numeric), and return an empty range instead of the Long.MAX_VALUE sentinel when the lower bound overflows (+18 lines in Lucene90DocValuesProducer). The test and 14 doc-values suites (738 tests) pass, assertions on and off.

All options and trade-offs3 options ยท 770 words

Two fixes were built and run in a scratch worktree of 645d4fe14a20; a third (revert) was not built. Option A follows the encoding gate costin suggested on #16896; option B is that PR.

Decode for 64-bit fields, and no Long.MAX_VALUE sentinel for an empty lower bound (validated by the checks) (recommended)

The study's three-part fix: (a) transformGcdBounds returns null (empty) when minValue - delta overflows upward; (b) the dense numeric gcd/delta rangeIntoBitSet runs the 10.5.2 decoded loop when bitsPerValue == 64; (c) hasGcdEncoding also requires bitsPerValue < 64, so sorted numeric uses decoded values. (b) and (c) are the gate costin suggested in a comment on #16896; (a) closes a narrower collision on gcd > 1 fields stored with fewer than 64 bits per value, where ceilDiv(Long.MAX_VALUE, gcd) can be a stored value. At query level the skip index prunes that case, so it shows only through direct rangeIntoBitSet calls.

   private static long[] transformGcdBounds(long minValue, long maxValue, long mul, long delta) {
     assert mul > 0;
+    if (delta < 0 && minValue > Long.MAX_VALUE + delta) {
+      // minValue - delta overflows: no stored value can reach the lower bound. Do not saturate to
+      // Long.MAX_VALUE here, since Long.MAX_VALUE (or ceilDiv(Long.MAX_VALUE, mul)) can itself be
+      // a stored value.
+      return null;
+    }
@@ ... @@
+                if (entry.bitsPerValue == 64) {
+                  // with 64 bits per value, the stored v - min can exceed Long.MAX_VALUE and read
+                  // back as a negative long: compare decoded values instead
+                  for (int d = fromDoc; d < toDoc; d++) {
+                    long v = mul * values.get(d) + delta;
+                    if (v >= minValue && v <= maxValue) {
+                      bitSet.set(d - offset);
+                    }
+                  }
+                  return;
+                }
                 Lucene90DocValuesProducer.rangeGcdDeltaIntoBitSet(
@@ ... @@
             && entry.table == null
+            && entry.bitsPerValue < 64 // 64-bit stored values can wrap: compare decoded values
             && (entry.gcd != 1 || entry.minValue != 0);

Trade-offs

  • DirectWriter rounds 57-64 required bits up to 64 (DirectWriter.java:230-235), so every field whose stored values need more than 56 bits (with gcd 1, a span of 2^56 or more), not only spans over Long.MAX_VALUE, goes back to the 10.5.x decoded loop and loses the #16160/#16285 speedup. Narrower fields keep the encoded path, including saturated open bounds such as [Long.MIN_VALUE, t] on timestamps, #16160's stated goal. No benchmark was run.
  • A variant could keep the SIMD kernel for 64-bit fields by running it on a decoding LongValues wrapper, as #16896 does (not built).

What the checks validated

  • Stock + test: fails with assertions on and off; + fix: passes with assertions on and off.
  • + fix: 14 suites, 738 tests (66 skipped), 0 failures, assertions on and off: TestSkipIndexedDocValuesRangeWideSpan, TestSkipBlockRangeIteratorBitSetOperations, TestDocValuesRangeIteratorIntoBitSet, TestDocValuesRangeIterator, TestSortedNumericDocValuesRangeIterator, TestDocValuesQueries, TestIndexSortSortedNumericDocValuesRangeQuery, TestIndexOrDocValuesQuery, TestBulkScorerConsistency, TestSortedSkipperScorerSupplier, the three TestLucene90DocValuesFormat* suites, TestCheckIndex; :lucene:core:check -x test passes.
  • With the patched file shadowed, the study's 11 tests (false-positive sentinels, gcd 7 and 454279, sorted numeric dense, sparse and variable cardinality, 100k latitudes and hashes) pass with assertions on and off; 10 of 11 fail without it.
  • Earlier, on branch_10_6 d04ee73177, the same three changes passed 5 regression tests and 3,803 package tests; removing (a) leaves the gcd = 454279 test failing. Z3 shows the transform with (a) correct for gcd == 1 at up to 63 bits per value and for gcd > 1 (a model, not the Java). Not run: the full test suite, nightly tests, benchmarks.

Open PR #16896 (kkewwei): decoded path whenever a bound shift overflows (validated by the checks)

Removes the saturating helpers; if minValue - delta or maxValue - delta overflows, runs the kernel on decoded values, else uses exact encoded bounds (+30/-40, head ab6a77d35e94, applies cleanly to 645d4fe14a20). Built and run: the test and the same 14 suites (738 tests) pass with assertions on.

Trade-offs

Correct, but, as costin pointed out on #16896, the check is per query: common open-ended ranges such as ts <= x ([Long.MIN_VALUE, x] on a field with a positive minimum) also leave the encoded path. Its test covers the API only, not query-level count, the false-positive sentinels or sparse sorted numeric.

What the checks validated

  • Test and 14 suites pass (gradle, assertions on); the candidate's standalone shadow run passes with assertions on and off.

Revert the #16160 override and #16285 transform on branch_10_6 (not built)

Restores the 10.5.x per-document decoded check on branch_10_6.

Trade-offs

Simplest to reason about under release pressure, but drops the speedup for every gcd/delta field.

Upstream status

Already reported, with a fix, in open PR #16896 by kkewwei (2026-10-23); it is not merged, has no milestone and lists itself under 10.7.0. costin commented on it on 2026-10-28. 10.6.0 RC1 contains the bug; the vote passed and 10.6.0 was released from it on 2026-10-29. The repro passes with the PR's change.

#16896
Open, unmerged, no milestone, no approval; head ab6a77d35e94 unchanged since 2026-10-23 (checked 2026-10-29; unchanged).
Comment
costin (2026-10-28, a conversation comment; the PR has no formal review): gate on the encoding (decoded path only when bitsPerValue == 64) instead of a per-query overflow check, and add open-bound and sparse sorted-numeric tests. No reply yet.
Release
10.6.0 RC1 (4ae946e5a9b, still the branch_10_6 head) fails the repro; its dev@ vote passed and 10.6.0 was released from it on 2026-10-29. Open #16930 removes 10.7.0, where #16896's CHANGES entry sits.
Repro on the PR
The test and 14 doc-values suites pass with #16896's production change applied to main 645d4fe14a20.

Downstream note

Applications that use a skip-indexed doc-values range query as a filter, for example to restrict results, would get wrong match sets on 10.6.0, mostly missing documents; this depends on downstream use, not on a Lucene guarantee for untrusted input.

How it was found

By reading the code: a survey that checked SIMD kernels against their scalar meaning followed the bound transform in front of the range kernel. Z3 on 64-bit bit-vectors then characterised exactly when it fails and checked a three-part fix, and an independent verifier reproduced it with its own tests on the stock jars.

The method in full172 words

Method: code reading (non-formal), during the study's SIMD translation-validation survey (run 3): the whole pipeline around DocValuesRangeSupport was translated, including the bound transform transformGcdBounds and the writer's encoding. Z3 (QF_BV 64-bit and NIA) then showed that with gcd 1 a false negative exists exactly when a stored value is negative (wrapped) and hi - min overflows, and a false positive needs a stored value equal to Long.MAX_VALUE (with gcd > 1, ceilDiv(Long.MAX_VALUE, gcd)); it found the fix's two rules correct on the model. Six JUnit tests failed on main and passed on 10.5.2.

  • A separate verifier wrote its own 11-case API and query matrix: 10 of 11 fail on main in both vector modes, all pass on 10.5.2 and with the fix; it showed that query-level false positives are reachable with 64 bits per value.
  • A 10.6 fact sheet and an independent review reproduced it on branch_10_6 and main, checked cross-version reads and validated a port of #16896.
  • This doc's test is a one-method, 2,048-document form of the verifier's latitude tests.

Prior reports, history and checks

Prior reports

Known: open PR #16896 (kkewwei, 2026-10-23) reports the same mechanism and fixes it. 216 logged queries on 2026-10-29 (GitHub, mailing lists, downstream repositories, the web) and the files of all 415 open PRs found no other report; refreshed the same morning.

Searches5 searches

#16896 ("fix long overflow in GCD-encoded DocValues range transform") describes the encoded range becoming [Long.MAX_VALUE, Long.MAX_VALUE] for the range [-1, 1]. It predates any public report from this hunt, so the bug is classed known. Related, not reports: #16160 and #16285 (introducing; the #16160 review discussed open bounds, not wrapped stored values); #16897 (same author, depends on #16896; alone it would extend the bug to fields without a skip index); #16917 (same kernel, does not fix it); #16835 (table encoding, not affected); #16884-#16886 (doc-values range wrong results on index-sorted or skip-indexed fields with document blocks or multi-valued fields, a different root cause; the issues have milestone 10.6.0 and were closed by #16893); the dev@ RC1 vote thread (nobody mentions #16896).

What this evidence adds to #16896: it is a 10.6.0 regression (10.5.2 correct, RC1 wrong); plain query-level count and search on ordinary latitudes fail (0 of 25,157); 10.5.2-written indexes read wrong without a reindex; tests for query-level count, false-positive sentinels and sparse and variable-cardinality sorted numeric, which costin asked for; and a fix that already follows costin's encoding gate.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; downstream repositories)2026-10-29a scripted prior-report search (144 queries, #16896 found at rank 2), 20 more GitHub queries and manual searches: transformGcdBounds, saturatingShiftLower, rangeGcdDeltaIntoBitSet, "GCD-encoded DocValues range", "doc values range overflow", "rangeIntoBitSet overflow"#16896 (same bug); #16160, #16285 (introducing); #16897, #16917, #16835 related
Open apache/lucene PRs2026-10-29changed-file lists of all 415 open PRsonly #16896 changes transformGcdBounds or the saturating helpers
Mailing lists2026-10-29dev@ (incl. the 10.6.0 RC1 vote and feature-freeze threads), java-user@, issues@, commits@no report; the vote thread does not mention #16896
JIRA archive2026-10-29a scripted prior-report search, 6 JQL queries (the code dates from 2026, after the migration)only unrelated issues
Refresh2026-10-29branch heads, Maven Central, #16896 state and conversation#16896 still open, no milestone, head unchanged; costin's comment unanswered

History

Introduced in 10.6.0 development by #16160 (merged 2026-07-02, numeric and single-valued sorted numeric) and #16285 (2026-07-13, multi-valued sorted numeric). All releases 10.0.0 to 10.5.2 are correct; main and 10.6.0 RC1 are affected.

Versions tested8 rows

#16160 "Optimize encoded numeric range bitsets" and #16285 "Apply GCD bound transform to sorted numeric rangeIntoBitSet" are by costin, both milestone 10.6.0 (9b68f633fdd6 and 263c3a4765f0 on main; cherry-picks 75f6e5deced and baa5f743f30 on branch_10x, ancestors of branch_10_6). Single-valued sorted numeric breaks with #16160 already because the query unwraps it to NumericDocValues. #16295 in between changes no result. Before 10.0.0 there is no doc-values skip index.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe test fails, assertions on and off (gradle and standalone, both vector providers); study tests 10 of 11 fail
branch_10_6 4ae946e5a9b (10.6.0 RC1) affectedthe test fails, assertions on and off; history probe 0 / 0 / 0 of 512
10.0.0 to 10.5.2 (12 releases) not affectedhistory probe 512 / 512 / 512; the test passes on 10.5.2, assertions on and off
9b68f633fdd6 (#16160) vs parent a7e1f014bc5 bisectnumeric and single-valued sorted numeric 512 -> 0; two-valued sorted numeric still 512 (same on branch_10x 75f6e5deced)
263c3a4765f0 (#16285) vs parent 4b982fffe24 bisecttwo-valued sorted numeric 512 -> 0 (same on branch_10x baa5f743f30)
index written by 10.5.2, read by RC1 cross-version100k latitudes [45, 90]: 25,157 under 10.5.2, 0 under RC1
9.12.3 no skip indexno doc-values skip index or indexedField (by reading)
with the recommended fix (main 645d4fe14a20) not affectedthe test, 14 doc-values suites and the study's 11 tests pass

Independent checks

Reproduced by the finder, an independent verifier with its own tests, a 10.6 fact sheet and its review, a reviewer from another model family with its own test, and runs of the minimal test on main and RC1. Both fixes pass the same tests.

Checks and verdicts306 words

Every check re-ran a repro on the stock jars. The cross-model review (OpenAI GPT-5.6) could not run any command in its first two attempts; the third ran its own test and found the bug real, with high confidence, on the single-valued numeric count path.

Each independent check and its verdict
CheckVerdictWhat it found
finder (study run 3) candidateCode reading; Z3 characterisation; 6 JUnit tests fail on main and pass on 10.5.2.
study verifier reproducedOwn 11-case matrix: 10 of 11 fail on main in both vector modes, 0 on 10.5.2 and with the fix; query-level false positives shown reachable.
10.6 fact sheet and review reproduced on branch_10_6 and main5 regression tests, a 25-check standalone repro, the cross-version read, a validated #16896 port; a 20,000-query differential check found 0 wrong with #16896 and 5,494 without.
candidate packaging realThe minimal test fails on 645d4fe14a20 with -ea and -da, both vector providers, 3 seeds; passes on 10.5.2; both fixes pass.
cross-model review, refute lens real (high confidence)Attempts 1 and 2 could not start any command and ended procedurally. Attempt 3 wrote its own test (257 raw longs near Long.MIN_VALUE plus 257 in [0, 256], skip-indexed, one segment): count 0 instead of 257 on main ea57360912ed with -ea and -da; the no-skip and narrow-span controls give 257; the study fix and #16896's change pass. Intended-behaviour argument: weak. Scope: the single-valued numeric count path only.
Follow-up known (#16896)Release matrix 10.0.0-10.5.2, RC1 and main; bisect of #16160 and #16285 on main and branch_10x; cross-version read; prior-report search.
this doc's runs reproduced, fix validatedThe one-method test through gradle on 645d4fe14a20 (-ea, -da); fix A with 14 suites and check; #16896's change with 14 suites; the study's 11 tests with fix A shadowed.

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 notes819 words

Finding record

LFV-044, snapshot of 2026-11-06

Registry title
Doc-values range queries with a skip index return wrong hits when a segment's values span more than Long.MAX_VALUE
Area
codecs / doc values
Priority
High, rank 3: A 10.6.0 regression that returns badly wrong range hits on ordinary signed-double or hash data with the opt-in skip index, and the outside fix is still unmerged.
Impact class
silent wrong results
Impact note
Default codec plus the opt-in doc-values skip index (indexedField) on ordinary data whose segment values span more than Long.MAX_VALUE (sign-crossing doubles of magnitude >= 2, 64-bit ids/hashes); 10.6.0 regression (released 2026-10-29) that also misreads indexes written by 10.5.2.
Summary
You index numeric doc values with a skip index (NumericDocValuesField.indexedField) where one segment's values span more than Long.MAX_VALUE, and run a doc-values range query. You expect the same hits as 10.5.2. Instead the encoded-domain bound transform wraps: 100k random latitudes queried for [45, 90] gave 0 hits instead of 25,157, 64-bit hashes gave 2 instead of 49,931, and some cases add wrong hits.
Introduced
#16160 (9b68f633fdd6) and #16285 (263c3a4765f0) (2026-07-02 (#16160); 2026-07-13 (#16285))
First affected version
10.6.0 (released 2026-10-29) and unreleased main
Affected versions
main b83101cdd2 and branch_10_6 d04ee73177 [exec]; 10.5.2 correct [exec]; main 645d4fe14a2 [exec 2026-10-29, -ea and -da: 10 of 11 fail]; branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b (10.6.0 RC1) have the identical Lucene90DocValuesProducer blob c9e4bc13f1c [read]; 10.6.0 release jars (rev 4ae946e5a9b) [exec 2026-11-02, seed DEADBEEF: minimal TestSkipIndexedDocValuesRangeWideSpan 1 of 1 and the two TestVerify classes 10 of 11 fail with -ea and -da]; main e1faeba08bd [exec 2026-11-02, -ea and -da: the TestVerify classes 10 of 11 fail]; branch_11x da3ea39fa59 (same producer blob dd67e555e5e as main) and branch_10_6 91eaab4fb29 (the 10.6.1 work; still the 10.6.0 blob c9e4bc13f1c) [read]; not targeted for 10.6.1 as of 2026-11-02 (#16896 has no milestone and files its CHANGES entry under 11.0.0); branch_10x was decommissioned on 2026-10-29 (#16911)
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
Found by
non-formal: code reading; also reached by Z3
Upstream and prior reports
outside open PR #16896 (kkewwei, opened 2026-10-23, 'fix long overflow in GCD-encoded DocValues range transform', no milestone); a port of its original 2026-10-23 version to branch_10_6 passed all its regression tests; costin reviewed #16896 on 2026-10-28: the per-query overflow check also drops the encoded path for open-ended ranges; they suggested gating on the encoding instead (decoded path only for 64-bit fields) and asked for open-bound and sparse sorted-numeric tests; kkewwei answered that review on 2026-10-29 with a refined per-query bounds check (canTransformGcdBounds, which keeps the encoded path when the transform cannot overflow and otherwise scans decoded values) and added rangeIntoBitSet-level open-bound and sparse sorted-numeric tests; head db2d783e394 (a merge of main, 2026-11-02) is still open and unmerged, with no milestone and no committer review; its CHANGES entry is under 11.0.0 Bug Fixes; main e1faeba08bd, branch_11x da3ea39fa59 and branch_10_6 91eaab4fb29 still have the old code (recheck 2026-11-02)
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28, upstream main on 2026-10-29): the repro test through gradle in a scratch worktree, assertions on and off, stock, with the recommended fix and with the production change of #16896; the standalone runner on main (both vector providers, three seeds), on jars built from the 10.6.0 RC1 commit 4ae946e5a9b and on the 10.5.2 release jars; the study's 11 tests on main with and without the recommended fix. Earlier runs: the study tests on main b83101cdd2 and a9779ef3c193 (10 of 11 fail); the 10.6 fact sheet's 5 regression tests, 25-check repro and 20,000-query differential check on branch_10_6 d04ee73177.

Lucene and upstream sources

Finding record
  • SECURITY.md:28, 54 (threat model)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesConsumer.java:417-422, 434-435, 505-507, 511, 553
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:550-563, 569-616, 1040, 1066-1076, 2002-2003, 2019-2023, 2095-2116, 2226-2240; document/SortedNumericDocValuesRangeQuery.java:138
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/internal/vectorization/DefaultDocValuesRangeSupport.java:39-41; lucene/core/src/java25/.../PanamaDocValuesRangeSupport.java:57, 106-107
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/DocValuesRangeIterator.java:49-60, 355-367, 447-453, 478-484; search/ConstantScoreScorerSupplier.java:81-92
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/ (only NumericDocValuesField, SortedNumericDocValuesField, SortedDocValuesField and SortedSetDocValuesField reference DocValuesSkipIndexType.RANGE)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:2080-2105 (sorted-numeric rangeIntoBitSet, no skipper check)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/NumericDocValuesField.java:51-58, 91-92; index/NumericDocValues.java:113-140; search/IndexSearcher.java:491
  • a Lucene clone: git show releases/lucene/10.5.2:lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java (lines 966-972)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:585-616
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/packed/DirectWriter.java:230-235

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 minimal repro and an executed probe of it on main 645d4fe14a20.

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.