All findings · Lucene finding · LFV-169 · indexeddisi-jump-table-count-short-overflow

IndexedDISI returns its jump-table entry count as a short, so a sparse field whose last document is at doc id 2,147,352,576 or above loses its jump table (8.0.0+)

In a segment of over 2,147,352,576 docs, a sparse doc-values, norms or vector field with a value at doc id 2,147,352,576 or above stores its jump-table entry count (32,768 or 32,769) as a short, which wraps negative. Readers skip the table, so advance() walks block by block; results stay correct.

Summary

Priority
Low, rank 951IndexedDISI's jump-table entry count overflows a short near 2^31 docs, so readers ignore the table and advance more slowly while results stay correct.
Impact
minor
Since
8.0.0 (LUCENE-8585, commit c13645bd4c6, 2019-01-18)introduced 2019-01-18 · about 7 years
Affected
8.0.0-10.6.0 and main; sparse vectors from 9.2.0
Status
documented
Found by
formal: a Z3 bit-vector model of the IndexedDISI layout, then executed tests

AI-assisted page: this finding was first signalled by a formal tool's own output 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-03.

The finding in 30 seconds

Lucene records which documents have a sparse field in blocks of 65,536 doc ids, followed by a jump table that lets advance() go straight to any block. The writer returns the table's entry count as a 16-bit short, which tops out at 32,767; a field whose last document is in block 32,766 or 32,767 needs 32,768 or 32,769 entries.

Diagram of the bug: doc ids split into blocks of 65,536; a field whose last document is doc 2,147,352,576 (block 32,766) gets 32,768 jump-table entries (blocks 0 to 32,766 plus the end marker). writeBitSet returns (short) 32768 = -32,768, the codec stores it, the reader treats a count of 0 or less as no jump table, and advance() walks the block headers one by one. With the last document in block 32,765 the count is 32,767 and the table is used.
A sparse field whose last document is in block 32,766: the writer emits 32,768 jump-table entries, the short cast turns the count into -32,768, and the reader, seeing a count of 0 or less, walks the blocks one by one instead of jumping. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Build a segment of more than 2,147,352,576 documents, for example by force-merging a near-full index (IndexWriter.MAX_DOCS is 2,147,483,519) into one segment, with a doc-values, norms or vector field that some documents lack and that doc 2,147,352,576 has.
  2. Lucene stores which documents have the field in blocks of 65,536 doc ids, then a jump table so advance() can go straight to any block: one entry per block up to the field's last one, block 32,766 here, plus one for the end marker. That is 32,768 entries.
  3. You expect IndexedDISI.writeBitSet to return 32,768, its documented "number of jump-table entries". It returns (short) 32768 = -32,768, and the codec stores that in the field's metadata.
  4. On read, a count of 0 or less means "no jump table", so the 256 KiB table is never used. With a document in every block, advance(2147352576) on a fresh iterator reads 32,767 block headers instead of jumping: about 0.5 ms instead of 48 us in memory. The values returned stay correct.
  5. One block earlier (last document in block 32,765, doc ids 2,147,287,040 to 2,147,352,575) the count is 32,767, which fits in a short, and the table works.

Who is affected

Default-codec users with one segment of more than 2,147,352,576 documents, in practice a near-full index force-merged into one segment, and a doc-values, norms or vector field that some documents lack, with a value at doc id 2,147,352,576 or above. Results stay correct; each iterator that skips far pays at most one extra pass over the block headers.

Mechanism, terms and numbers669 words

IndexedDISI records which documents of a segment have a sparse doc-values, norms or vector field, in blocks of 65,536 doc ids. Since 8.0 it also writes a jump table after the blocks, one entry per block up to the last one with a document plus one for the end marker (no table when every document is in block 0), so that advance() can jump straight to a far block. writeBitSet returns the number of entries as a short, under the comment "As there are at most 32k blocks, the count is a short". Legal doc ids fill blocks 0 to 32,767, so up to 32,769 entries are written. When the field's last document is at doc id 2,147,352,576 or above, the count is 32,768 or 32,769 and the cast returns -32,768 or -32,767. The default doc-values, norms and vector writers store that value; their readers take a count of 0 or less as "no jump table" and advance()/advanceExact() walk the block headers one by one. The documents and values returned stay correct; about 256 KiB of table per affected field per segment is written and never read. Every release from 8.0.0 to 10.6.0 and main is affected.

Where it happens (main 645d4fe14a20, codecs/lucene90/IndexedDISI.java)

  1. One entry per block up to the last doc's block, plus one. In writeBitSet (:192-236) each block with documents calls addJumps(..., lastBlock, block + 1) and sets lastBlock = block + 1 (:215-216); after the loop one more entry is added for the NO_MORE_DOCS block (:231), and the method returns flushBlockJumps(jumps, lastBlock + 1, out) (:235). Empty blocks before the last one get entries too (they point at the next non-empty block). So, for a field whose last doc is past block 0, the count is the last doc's block + 2 (if every doc is in block 0 that is 2, and flushBlockJumps writes no table and returns 0, :255-259).
  2. The count is narrowed to a short.
// As there are at most 32k blocks, the count is a short          // :264
// The jumpTableOffset will be at lastPos - (blockCount * Long.BYTES)
return (short) blockCount;                                         // :266
  1. The default codecs store it. Lucene90DocValuesConsumer.writeDISI (:166-174), Lucene90NormsConsumer (:116-119) and lucene95/OrdToDocDISIReaderConfiguration.writeStoredMeta (:89-93, used by the vector formats) write it with meta.writeShort(jumpTableEntryCount).
  2. The readers take it as "no table". Lucene90DocValuesProducer (:388, :420), Lucene90NormsProducer (:223) and OrdToDocDISIReaderConfiguration.fromStoredMeta (:129) read it back with readShort(). createBlockSlice treats a count below 0 as no table bytes (:375), and createJumpTable returns null for a count of 0 or less (:392-393).
  3. advance walks. advanceBlock uses the table only when jumpTable != null (:498); otherwise it takes the "Fallback to iteration of blocks" (:512-516): seek to the end of the current block, read the next block header, repeat. A DENSE block also has its 256-byte rank table read (:549-550).

The numbers

block of the field's last docits first doc identries writtenwriteBitSet returnsjump table
32,7652,147,287,04032,76732,767used
32,7662,147,352,57632,768-32,768ignored
32,7672,147,418,11232,769-32,767ignored

IndexWriter.MAX_DOCS - 1 = 2,147,483,518 (index/IndexWriter.java:208) is in block 32,767, so no legal doc id goes further. Only sparse fields are affected: a field that every document has, or none has, stores -1 and no IndexedDISI (Lucene90DocValuesConsumer.java:461-470, and the same in the norms and vector writers).

The table bytes on disk are valid; only the stored count is wrong. Reading the same bytes with the count taken as an unsigned 16-bit value restores the jumps and gives the same results. A Z3 model of the layout proves that a wrapped count always reads back negative and that every last doc id below 2,147,352,576 keeps a correct count; that a negative count then switches the table off, so it is never misread, follows from the code (:375, :392-393).

Exposure and likelihood341 words
  • Who is hit. The default codec, doc values and norms from 8.0.0, sparse vectors from 9.2.0. No setting turns the jump table on or off: it is written for every sparse field whose last document is at doc id 65,536 or above (block 1 or later). Only doc values were run end to end; norms and vectors store and read the count the same way (by reading). For vectors, iteration, exact search and merging go through IndexedDISI; ordinary HNSW graph search works on ordinals and may not touch it (not checked).
  • How such a segment arises. The default TieredMergePolicy caps merged segments at 5 GiB (TieredMergePolicy.java:77), under about 2.5 bytes per document for 2^31 documents, so in practice it takes a forceMerge of a near-full index to very few segments, or a raised cap. Such segments are supported: Lucene's @Monster test TestIndexWriterMaxDocs.testExactlyAtTrueLimit indexes MAX_DOCS documents and calls forceMerge(1).
  • Cost. The walk only moves forward, so each iterator pays at most one extra pass over the non-empty blocks it passes, in its lifetime, not per call (but each query, segment, sort or facet makes its own iterators): up to 32,768 header reads (a document in every block 0 to 32,767), plus the rank table of each DENSE block, and one extra header read for a field with two documents. Measured medians, in memory on a loaded shared host, so order of magnitude only, no table against the table: one far advance from a fresh iterator 477 us against 48 us (one doc per block) and 1.9 ms against 69 us (4,096 docs per block); about 1,000 sorted advanceExact calls 512 us against 95 us, and 2.0 ms against 0.38 ms. For scale, a full scan of the dense field took 852 ms.
  • Space. About 256 KiB of table per affected field per segment is written and never read.
  • Visibility. None: nothing fails or warns. In effect the segment falls back to the pre-8.0 block walk: a 7.x baseline with the same doc set takes the same 32,767-seek walk.

Minimal repro

One JUnit test, one method, one document: writeBitSet on the single doc 2,147,352,576, in the codec's package like Lucene's own TestIndexedDISI. Deterministic, about 1 s, same result with assertions on or off.

TestIndexedDISIJumpTableCount.java (one test)39 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.codecs.lucene90;

import org.apache.lucene.search.DocIdSetIterator;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIndexedDISIJumpTableCount extends LuceneTestCase {

  public void testJumpTableEntryCount() throws Exception {
    int doc = 32766 << 16; // 2,147,352,576 < IndexWriter.MAX_DOCS
    try (Directory dir = new ByteBuffersDirectory();
        IndexOutput out = dir.createOutput("disi", IOContext.DEFAULT)) {
      assertEquals(
          "expected the 32768 jump-table entries written (blocks 0..32767), actual the short cast",
          32768,
          IndexedDISI.writeBitSet(
              DocIdSetIterator.range(doc, doc + 1), out, IndexedDISI.DEFAULT_DENSE_RANK_POWER));
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestIndexedDISIJumpTableCount.java lucene/core/src/test/org/apache/lucene/codecs/lucene90/
./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene90.TestIndexedDISIJumpTableCount -Ptests.seed=DEADBEEF
# assertions off (production): add -Ptests.asserts=false; the result is the same
Expected
writeBitSet returns 32,768, the number of jump-table entries it wrote.
Observed
It returns -32,768 (expected:<32768> but was:<-32768>), with assertions on and off.

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

More files, environment, full output297 words

One document in block 32,766 is the smallest input that wraps: the writer still emits entries for the empty blocks 0 to 32,765, so the count is 32,766 + 1 + 1 = 32,768 (12 bytes of blocks, then 32,768 x 8 = 262,144 bytes of table). A last document one block earlier gives 32,767, which fits. The test holds the result in no short variable, so it compiles unchanged against an int return type.

As the regression test: the same test; it fails without the fix and passes with the recommended fix. It checks the writer's count; that readers then use the table was checked separately while validating the fix.

The consequence, not part of the repro: with one document in every block 0 to 32,766, advance(2147352576) on a fresh iterator reads 32,767 block headers, on the raw IndexedDISI and through Lucene90DocValuesFormat (a sparse NUMERIC field of a segment with maxDoc = IndexWriter.MAX_DOCS, written and read through the format's consumer and producer; no index of 2^31 documents was built). Through the format, advanceExact(2147352576) also takes 32,767 seeks and returns the right value (an independent reproducer's codec test on ea57360912ed, and the diagram probe on 645d4fe14a20).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0 and on every release from 8.0.0 to 10.5.2
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF
TestIndexedDISIJumpTableCount > testJumpTableEntryCount FAILED
    java.lang.AssertionError: expected the 32768 jump-table entries written (blocks 0..32767), actual the short cast expected:<32768> but was:<-32768>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:AD89756848C50AE2]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.apache.lucene.codecs.lucene90.TestIndexedDISIJumpTableCount.testJumpTableEntryCount(TestIndexedDISIJumpTableCount.java:32)

# the hunt's consequence tests (main 645d4fe14a20, gradle, count held in an int; same on ea57360912ed)
testAdvanceUsesTheJumpTable: advance to the last block took 32767 block seeks (count=-32768)
testDocValuesFieldIgnoresItsJumpTable: advance to the last block took 32767 block seeks

Why it looks unintended

  • writeBitSet must return "the number of jump-table entries ... -1 for no entries"; -32,768 is neither.
  • Its comment "at most 32k blocks, the count is a short" is wrong: legal doc ids fill blocks 0 to 32,767, plus an end entry.
  • The table exists "for O(1) advance" (LUCENE-8585); writing 256 KiB that no reader uses is no trade-off.
The full argument200 words
  • Contract. @return the number of jump-table entries following the blocks, -1 for no entries (IndexedDISI.java:188-189, also on the 2-argument overload at :167). The constructors say the count "must match the number returned by" writeBitSet (:288-289, :318-319).
  • The code's own invariant is false. "As there are at most 32k blocks, the count is a short" (:264); doc ids up to MAX_DOCS - 1 fill blocks 0 to 32,767, and the writer adds the NO_MORE_DOCS entry: up to 32,769.
  • Purpose. Class javadoc: "To avoid O(n) lookup time complexity ... lookup tables are used"; the table "allows for direct jumping to the block, as opposed to iteration from the current position and forward one block at a time" (:56-62). CHANGES.txt:6877: "LUCENE-8585: Index-time jump-tables for DocValues, for O(1) advance when retrieving doc values."
  • No misuse. The wrapped value comes from Lucene's own default writers.
  • No test or discussion covers it. TestIndexedDISI uses small doc-id ranges; the Test2B*DocValues monster tests give every document a value, which is stored without IndexedDISI. In the LUCENE-8585 review (lucene-solr#525) the only remark on the line moved the count to the meta file; its width was never discussed.
The devil's-advocate check147 words

Strongest case (an intended-behaviour checker and a works-as-designed review by another model family): IndexedDISI is @lucene.internal (:94), so the @return javadoc is an internal contract; DocIdSetIterator promises correct results, not O(1) cost; Lucene90DocValuesFormat documents both modes, skipping "either done on an iterative basis or by using the jump-table" (Lucene90DocValuesFormat.java:65-66); the fallback is safe; and the trigger is extreme.

Why it fails: these limit severity, not validity. The iterative mode is a general path that happens to absorb an arithmetic slip (the reviewer: "an arithmetic oversight accommodated by a general fallback", rating the intended argument moderate); nothing says that a valid segment may lose its table; the code comment shows the count was meant to always fit; and the trigger is legal: Lucene's own monster tests build segments of that size. Verdict: a defect, with low priority because of the scale it needs.

Suggested fix

Recommended Return the count as an int, keep storing 16 bits, decode a wrapped value when reading

Return the count as an int, keep storing the same 16 bits, and decode a stored value below -1 as unsigned when reading (+33/-18 lines in 6 files, no format change), so segments already written get their table back. With it the repro passes with assertions on and off, as do 28 neighbouring suites (1,049 tests) and the static checks.

static int decodeJumpTableEntryCount(int jumpTableEntryCount) {
  return jumpTableEntryCount < -1 ? jumpTableEntryCount & 0xFFFF : jumpTableEntryCount;
}
All options and trade-offs3 options ยท 636 words

One option was validated through gradle; the hunt validated a read-side-only variant by shadowing the class.

Return the count as an int, keep storing 16 bits, decode a wrapped value when reading (validated by the checks) (recommended)

writeBitSet (both overloads) and flushBlockJumps return int; the comment and the @return javadoc say the count can be up to 32,769. A package-private helper decodes a stored count in createBlockSlice, createJumpTable and the constructor the public one delegates to: -1 and 0 still mean no table, -32,768 and -32,767 become 32,768 and 32,769 (today's writers store no other negative value; a corrupt or foreign value from -32,766 to -2 would decode to 32,770 to 65,534, so the fix relies on the checksummed metadata, or could also check the decoded count against the DISI length). The writers store (short) jumpTableEntryCount: Lucene90DocValuesConsumer, Lucene90NormsConsumer, OrdToDocDISIReaderConfiguration, and two test-only writers in backward-codecs that assigned the result to a short. +33/-18 lines in 6 files.

static int decodeJumpTableEntryCount(int jumpTableEntryCount) {
  return jumpTableEntryCount < -1 ? jumpTableEntryCount & 0xFFFF : jumpTableEntryCount;
}

Trade-offs

  • No format change: the 16 bits on disk are the same as today, so segments already written in the 9.0+ formats get their table back once read by the fixed code.
  • The return type of a public @lucene.internal method changes: binary incompatible for outside callers, and those that assign it to a short need a cast.
  • The backward-codecs lucene80 copy, which reads 8.x-format segments, keeps the old behaviour; the same helper there would fix it too (not validated).

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20: without the fix the repro fails, and so do the hunt's three tests (the count check plus two consequence tests; 32,767 block seeks); with the fix all four pass, with assertions on and off (-Ptests.asserts=false): writeBitSet returns 32,768, and advance() and a sparse doc-values field written and read through Lucene90DocValuesFormat use the jump table again (at most 2 seeks).
  • The closest suites pass: every class in codecs.lucene90 (TestIndexedDISI, TestLucene90DocValuesFormat and its MergeInstance and VariableSkipInterval variants, TestLucene90NormsFormat and its MergeInstance variant, and the rest), codecs.lucene95 and codecs.lucene99 (vector formats), TestDocValues, TestNorms and TestCheckIndex: 28 classes, 1,049 tests, 0 failures, 87 skipped. In backward-codecs, TestLucene92HnswVectorsFormat and TestLucene94HnswVectorsFormat, whose test writers changed: 116 tests, 0 failures, 6 skipped.
  • ./gradlew :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs) and the backward-codecs format and ecj checks pass with the repro test in place. The full test suite was not run.
  • Limits: the executed checks cover the 32,768-entry case (last doc in block 32,766) on the raw IndexedDISI and a sparse doc-values field. The 32,769-entry case (a real block 32,767, which shares its block number with the end block), norms and vectors were checked by reading the code only; no executed test covers 32,769 entries.

Decode on the read side only, document the return value as unsigned 16 bits (validated by the checks)

The same decode helper in IndexedDISI's readers; writeBitSet keeps returning short, so its @return javadoc would also have to say the value is an unsigned 16-bit count (that change is not in the hunt's sketch).

Trade-offs

Smallest change, no signature change, restores the table for existing and new segments. But the method keeps returning -32,768, so its documented contract has to change, and the minimal repro keeps failing.

What the checks validated

Hunt, main ea57360912ed, class shadowed: the two consequence tests pass (2 run, 0 failed) and TestIndexedDISI passes (15 run, 0 failed, 3 skipped).

Store the count as an int in the metadata

Write and read the count with writeInt/readInt.

Trade-offs

Needs a new format version and back-compatible reading in three formats, for a value that already fits in 16 bits unsigned. Not tried.

Downstream note

Downstream, Elasticsearch's DISIAccumulator (elastic/elasticsearch#126499) copies flushBlockJumps with the same (short) blockCount and stores it with writeShort, so it inherits the wrapped count.

How it was found

Formal: a Z3 bit-vector model of the IndexedDISI layout arithmetic (38 obligations) found that the jump-table entry count does not fit the short it is returned in, and gave the smallest failing last doc id, 2,147,352,576. A bounded-exhaustive test and a JUnit on the stock jars then confirmed it.

The method in full157 words

Method: formal (the solver's own counterexample). A hunt unit modelled the IndexedDISI layout in Z3 4.12.2 bit-vector arithmetic (38 obligations) over doc ids below IndexWriter.MAX_DOCS.

  • The obligation "the jump-table entry count survives the short return value" was SAT; z3 Optimize gave the smallest failing last doc id, 2,147,352,576 (= 32766 << 16, 32,768 entries, returned -32,768).
  • Two more obligations proved (unsat) that every smaller last doc id keeps a correct count and that a lost count always reads back negative; by the code (:375, :392-393) a negative count disables the table, so it is never misread.
  • The unit's bounded-exhaustive test on the stock jar flagged the same count mismatch on every set with more than 32,767 entries, and no read mismatch.
  • A deterministic JUnit on the stock main jars then reproduced it through the public API, and through Lucene90DocValuesFormat on a SegmentInfo with maxDoc = IndexWriter.MAX_DOCS, so no 2^31 documents were indexed.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, the open PRs' diffs, the JIRA archive, the Lucene and Solr lists, downstream trackers or the web (227 queries and reads, 2026-10-26); not fixed upstream.

Searches5 searches

Closest items, none a report of this bug: #9631 and lucene-solr#525 (the origin; the "at most 32k blocks" comment sat in reviewed hunks, unquestioned), #10093 (open, random-access seeks), open PR #12557 (adds a writeBitSetInternal returning an int[] {count, doc count} but keeps writeBitSet returning short, and the count still comes from the short flushBlockJumps), #16540 (merged 2026-09, keeps the short), #15430 (documents the count as [int16]), and elastic/elasticsearch#126499 (copies flushBlockJumps with the same short, without mentioning it). Limits: GitHub search does not index code or diffs; the Elasticsearch and OpenSearch forums were covered by web search only; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs (incl. the migrated JIRA issues) and commits2026-10-26two runs of the hunt's prior-report tool plus own searches: 50 issue/PR queries and 7 commit queries; class, method and field names, code fragments ("(short) blockCount", "at most 32k blocks"), the numbers 32768 and 2147352576, symptom phrasesno report
Open PRs2026-10-26changed files of all 413 open PRs; the 23 that touch the code grepped for the countnone changes the count
JIRA archive, mailing lists, downstream trackers, web2026-10-2618 JIRA queries; 80 queries over dev@, java-user@, issues@ and commits@; 30 over general@ and the Solr lists; 10 over the Elasticsearch, OpenSearch and Solr trackers; 14 web queriesno report; one unrelated list hit
Upstream branches2026-10-26file contents on main, branch_10x and branch_10_6all still return (short) blockCount;
Upstream branches (recheck)2026-11-01file contents on main (2f424fd428d, 2026-10-30), branch_11x and branch_10_6; branch_10x was retired on 2026-10-29 (#16911)all still return (short) blockCount;

History

Introduced in 8.0.0 by LUCENE-8585 (c13645bd4c6, 2019-01-18), which created the jump table and the short count together. Every release from 8.0.0 to 10.6.0 and main fails; 7.x has no jump table.

Versions tested5 rows

Introduced by LUCENE-8585 "Create jump-tables for DocValues at index-time" (now #9631), developed in apache/lucene-solr#525 by Toke Eskildsen and reviewed by Adrien Grand; master commit c13645bd4c6 (2019-01-18), branch_8x cherry-pick 5bb6a5b42c4 (2019-01-21); first release 8.0.0 (released 2019-03-14, tagged 2019-03-13). Neither parent has lucene80/IndexedDISI.java; in 7.x writeBitSet returns void. Sparse vectors joined in 9.2.0 (LUCENE-10502). Later changes (#14552, #11923, the lucene80 to lucene90 copy) left the count alone; the backward-codecs lucene80 copy has the same code (IndexedDISI.java:283).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (standalone and gradle); passes with the fix
10.6.0 affectedminimal repro fails with assertions on and off
10.0.0-10.5.2 (12 releases) affectedthe hunt's tests fail, including the doc-values-format test; 10.5.2 also with assertions off
8.0.0-9.12.3 (48 releases) affectedthe IndexedDISI-level tests fail; 8.0.0, 9.0.0 and 9.12.3 also with assertions off
7.7.0, 7.7.1, 7.7.3 no jump tablethe repro does not compile (no lucene80.IndexedDISI); a 7.x baseline takes the same block walk by design

Independent checks

Confirmed with high confidence: an independent reproducer, an intended-behaviour devil's advocate, reviews by another model family (OpenAI GPT-5.6) and an adjudication found it real; all but the adjudication ran their own tests, and the adjudication re-ran the reproducer's and the finder's tests with new seeds. Re-run on 645d4fe14a20; the fix validated through gradle.

Checks and verdicts223 words

No check dissented; all agreed it is performance and space only.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateZ3 model (the count obligation sat, its two companion obligations unsat); bounded-exhaustive test; JUnit fails 3/3 on main with assertions on and off and on 10.5.2, 2/2 on 9.12.3 and 8.11.4.
review (OpenAI GPT-5.6, refute lens) real, highIts own two-document boundary test: block 32,765 gives 32,767, block 32,766 gives -32,768, with assertions on and off.
independent reproducer realIts own tests, with entries counted from the file length and seeks counted through a wrapping input: -32,768 and -32,767 at blocks 32,766 and 32,767; 32,767 seeks, against 3 when the same bytes are read with the count as unsigned; correct values. Fails on main, 10.5.2 and 9.12.3.
works-as-designed review (OpenAI GPT-5.6) real, highRated the internal-API / no-O(1)-promise / safe-fallback argument moderate and rejected it; its own test fails on main and 10.5.2.
intended-behaviour devil's advocate realThe arguments limit severity, not validity; its boundary test fails on main and 10.5.2.
adjudication confirmed, highRe-ran the independent reproduction's two tests and the finder's repro with new seeds: fail as claimed.
re-verification still reproducesMain ea57360912ed (assertions on and off), a9779ef3c193 (assertions on) and 645d4fe14a20 (assertions on and off); 10.6.0 (on and off).

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

Finding record

LFV-169, snapshot of 2026-11-03

Registry title
IndexedDISI returns its jump-table entry count as a signed short, so a sparse doc-values, norms or vector field whose last doc id is >= 2,147,352,576 stores a negative count and every reader ignores the jump table that was written (advance walks block by block; results stay correct)
Area
codecs / IndexedDISI jump table (presence sets of sparse doc values, norms and vectors)
Priority
Low, rank 951: IndexedDISI's jump-table entry count overflows a short near 2^31 docs, so readers ignore the table and advance more slowly while results stay correct.
Impact class
minor
Impact note
Performance and space only, with correct results: a user of the default codec whose single segment has maxDoc above 2,147,352,576 (within about 131k docs of IndexWriter.MAX_DOCS, in practice a near-full index force-merged into one segment) and a doc-values, norms or vector field that some documents lack, with its last document in that tail, gets about 256 KiB of jump table per such field that is written but never used, and each fresh iterator on that field that skips ahead pays at most one extra forward walk over up to about 32,767 block headers (plus a 256-byte rank read per DENSE block passed; measured about 0.5 ms sparse and 2 ms dense in memory) instead of O(1) block jumps; default configuration, extreme scale.
Summary
You build a segment of more than 2,147,352,576 documents with the default codec, with a doc-values, norms or vector field that some documents lack and that a document near the end of the doc-id range has. You expect the field's index-time jump table (added in 8.0 'for O(1) advance') to be used, so that advance() to a far document jumps straight to its 65,536-doc block. Instead IndexedDISI.writeBitSet writes 32,768 or 32,769 table entries but returns their count as a short (-32,768 or -32,767), the codec stores that value, and every reader treats a negative count as 'no jump table', so advance() and advanceExact() walk the block headers one by one; the documents and values returned stay correct.
Introduced
c13645bd4c6 (c13645bd4c65f052e7f35df8e33d3dbfe6d7cd58, LUCENE-8585 'Create jump-tables for DocValues at index-time', now apache/lucene#9631; developed in apache/lucene-solr#525 by tokee, reviewed by jpountz; committed directly to master, branch_8x cherry-pick 5bb6a5b42c4) [read: git blame -w -C -C and git log -S '(short)blockCount' over the full history return only these two commits; neither parent has lucene80/IndexedDISI.java, and lucene70 writeBitSet returns void] [exec: 7.7.0, 7.7.1 and 7.7.3 lack the API (compile_error), 8.0.0 fails] (2019-01-18 (master commit); branch_8x cherry-pick committed 2019-01-21)
First affected version
8.0.0 [exec] (tagged 2019-03-13; 7.x has no jump table, so the repro does not compile on 7.7.0, 7.7.1 or 7.7.3 [exec]); the sparse-vector path from 9.2.0 (LUCENE-10502) [read]
Affected versions
every release from 8.0.0 through 10.6.0 (10.6.0 [exec 2026-11-01]: the minimal test fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da), and main ea57360912ed [exec: the follow-up's history matrix (85 runs) fails on all 60 releases 8.0.0-10.5.2 with -ea (the two IndexedDISI-level tests; plus the Lucene90DocValuesFormat test on 10.0.0-10.5.2), with -da on 8.0.0, 9.0.0, 9.12.3 and 10.5.2, and on main with -ea and -da, 78 failing runs in all with the same messages; a 7.x baseline with the same doc set passes and takes the same 32767 block seeks by design] [read: branch_10x and branch_10_6 still have 'return (short) blockCount;'; doc values and norms from 8.0.0, sparse vectors from 9.2.0; the backward-codecs lucene80 copy that reads 8.x indexes has the same code]; not a regression; not fixed upstream (no open PR changes the count; #12557 keeps it a short) [read 2026-10-26]; executed on aarch64 with JDK 25 only
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
formal: Z3; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, verdict new: 227 logged queries and reads: scripted prior-report searches 1 and 2 plus own searches over apache/lucene issues and PRs (50) and commits (7), the Elasticsearch, OpenSearch and Solr trackers (10), the JIRA archive (18), the dev@, java-user@, issues@ and commits@ lists (80), the general@, solr-user@ and solr.apache.org lists (30) and the web; the changed-file lists of all 413 open apache/lucene PRs (23 touch the code, none changes the count); a local full-history git search): no issue, PR, commit, review comment, list post or downstream item reports or discusses it; nearest items: the introducing LUCENE-8585 (#9631) and its review lucene-solr#525 (the 'at most 32k blocks' comment sat in reviewed hunks, nobody questioned it), #10093 (LUCENE-9051, open, random-access seeks), open PR #12557 (changes writeBitSet's return type but keeps the short count), #16540 (merged 2026-09, keeps the short); downstream, elastic/elasticsearch#126499 (DISIAccumulator) copies flushBlockJumps with the same short, so it inherits the defect, but nobody there reports it; not fixed upstream (main, branch_10x and branch_10_6 unchanged); gaps: GitHub search does not index code or diffs, and the Elasticsearch and OpenSearch forums were covered only by web search
Lucene commit tested
main 645d4fe14a20: the one-test repro with assertions on and off as a standalone JUnit run, and through gradle in a scratch worktree without the fix (fails) and with it (passes, plus the closest core and backward-codecs suites and :lucene:core:check -x test); release jar 10.6.0 (fails with assertions on and off). The hunt (main ea57360912ed, where the files involved are byte-identical) ran the finder's and the verifiers' tests on main, 10.5.2, 9.12.3 and 8.11.4, and a history matrix of 85 runs over every release from 7.7.0 to 10.5.2. Linux aarch64, OpenJDK 25.0.3.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/lucene90/IndexedDISI.java:53 ('6 bytes per document in the worst-case'), :56-62 (lookup tables 'To avoid O(n) lookup time complexity'; 'allows for direct jumping to the block'), :70-71 ('a maximum of 2^16 blocks'), :88 ('65536-2 = 65634'), :94 (@lucene.internal), :188-192 (writeBitSet @return 'the number of jump-table entries following the blocks, -1 for no entries'; 'public static short writeBitSet'), :224-235 (NO_MORE_DOCS entry; 'return flushBlockJumps(jumps, lastBlock + 1, out)'), :253-267 (flushBlockJumps: '// As there are at most 32k blocks, the count is a short', 'return (short) blockCount;'), :288 (constructor @param: the count 'must match the number returned by' writeBitSet), :372-398 (createBlockSlice count < 0 -> no table bytes at :375; createJumpTable count <= 0 -> null at :392), :495-517 (advanceBlock: jump table only when jumpTable != null; 'Fallback to iteration of blocks' at :512)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesConsumer.java:166-174 (writeDISI: meta.writeShort(jumpTableEntryCount)); Lucene90DocValuesProducer.java:388, :420 (meta.readShort()); Lucene90NormsConsumer.java:116-119; Lucene90NormsProducer.java:223; lucene95/OrdToDocDISIReaderConfiguration.java:89-93 (writeShort), :129 (readShort); Lucene90DocValuesFormat.java:65 ('Skipping blocks ... is either done on an iterative basis or by using the jump-table')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:208 (MAX_DOCS = Integer.MAX_VALUE - 128); lucene/CHANGES.txt:6844 ('LUCENE-8585: Index-time jump-tables for DocValues, for O(1) advance when retrieving doc values.'); lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene80/IndexedDISI.java:283 ('return (short) blockCount;')
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/IndexedDISI.java:192-236, 215-216, 231, 235, 253-267 (264, 266), 375, 392-393, 498, 512-516, 549-550
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesConsumer.java:166-174, 461-470; Lucene90DocValuesProducer.java:388, 420
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90NormsConsumer.java:102-119; Lucene90NormsProducer.java:223
  • lucene/core/src/java/org/apache/lucene/codecs/lucene95/OrdToDocDISIReaderConfiguration.java:76-93, 129
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:208 (MAX_DOCS = Integer.MAX_VALUE - 128)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:77 (5 GiB max merged segment); LiveIndexWriterConfig.java:143 (default merge policy)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterMaxDocs.java:47-90 (@Monster, MAX_DOCS docs, forceMerge(1))
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/IndexedDISI.java:56-62, 94, 167, 188-189, 264, 288-289, 318-319
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesFormat.java:65-66
  • lucene/CHANGES.txt:6877 (LUCENE-8585)
Suggested fix
  • lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene80/IndexedDISI.java:283

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 code at 645d4fe14a20; every number is the arithmetic of writeBitSet for a last document at doc id 2,147,352,576.

Built 2026-11-03 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.