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.
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.- Build a segment of more than 2,147,352,576 documents, for example by force-merging a near-full index (
IndexWriter.MAX_DOCSis 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. - 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. - You expect
IndexedDISI.writeBitSetto 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. - 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. - 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.
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.
- One entry per block up to the last doc's block, plus one. In
writeBitSet(:192-236) each block with documents callsaddJumps(..., lastBlock, block + 1)and setslastBlock = block + 1(:215-216); after the loop one more entry is added for theNO_MORE_DOCSblock (:231), and the method returnsflushBlockJumps(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, andflushBlockJumpswrites no table and returns 0,:255-259). - 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- The default codecs store it.
Lucene90DocValuesConsumer.writeDISI(:166-174),Lucene90NormsConsumer(:116-119) andlucene95/OrdToDocDISIReaderConfiguration.writeStoredMeta(:89-93, used by the vector formats) write it withmeta.writeShort(jumpTableEntryCount). - The readers take it as "no table".
Lucene90DocValuesProducer(:388,:420),Lucene90NormsProducer(:223) andOrdToDocDISIReaderConfiguration.fromStoredMeta(:129) read it back withreadShort().createBlockSlicetreats a count below 0 as no table bytes (:375), andcreateJumpTablereturnsnullfor a count of 0 or less (:392-393). advancewalks.advanceBlockuses the table only whenjumpTable != 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).
| block of the field's last doc | its first doc id | entries written | writeBitSet returns | jump table |
|---|---|---|---|---|
| 32,765 | 2,147,287,040 | 32,767 | 32,767 | used |
| 32,766 | 2,147,352,576 | 32,768 | -32,768 | ignored |
| 32,767 | 2,147,418,112 | 32,769 | -32,767 | ignored |
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
TieredMergePolicycaps merged segments at 5 GiB (TieredMergePolicy.java:77), under about 2.5 bytes per document for 2^31 documents, so in practice it takes aforceMergeof a near-full index to very few segments, or a raised cap. Such segments are supported: Lucene's@MonstertestTestIndexWriterMaxDocs.testExactlyAtTrueLimitindexesMAX_DOCSdocuments and callsforceMerge(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
advancefrom 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 sortedadvanceExactcalls 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.
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.
/*
* 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));
}
}
}
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
writeBitSetreturns 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.
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).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0 and on every release from 8.0.0 to 10.5.2 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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 seekswriteBitSetmust 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 toMAX_DOCS - 1fill blocks 0 to 32,767, and the writer adds theNO_MORE_DOCSentry: 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.
TestIndexedDISIuses small doc-id ranges; theTest2B*DocValuesmonster tests give every document a value, which is stored withoutIndexedDISI. 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.
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.internalmethod changes: binary incompatible for outside callers, and those that assign it to ashortneed a cast. - The backward-codecs
lucene80copy, 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):writeBitSetreturns 32,768, andadvance()and a sparse doc-values field written and read throughLucene90DocValuesFormatuse the jump table again (at most 2 seeks). - The closest suites pass: every class in
codecs.lucene90(TestIndexedDISI,TestLucene90DocValuesFormatand itsMergeInstanceandVariableSkipIntervalvariants,TestLucene90NormsFormatand itsMergeInstancevariant, and the rest),codecs.lucene95andcodecs.lucene99(vector formats),TestDocValues,TestNormsandTestCheckIndex: 28 classes, 1,049 tests, 0 failures, 87 skipped. In backward-codecs,TestLucene92HnswVectorsFormatandTestLucene94HnswVectorsFormat, 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
IndexedDISIand 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).
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, Elasticsearch's DISIAccumulator (elastic/elasticsearch#126499) copies flushBlockJumps with the same (short) blockCount and stores it with writeShort, so it inherits the wrapped count.
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 Optimizegave 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
Lucene90DocValuesFormaton aSegmentInfowithmaxDoc = IndexWriter.MAX_DOCS, so no 2^31 documents were indexed.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs (incl. the migrated JIRA issues) and commits | 2026-10-26 | two 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 phrases | no report |
| Open PRs | 2026-10-26 | changed files of all 413 open PRs; the 23 that touch the code grepped for the count | none changes the count |
| JIRA archive, mailing lists, downstream trackers, web | 2026-10-26 | 18 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 queries | no report; one unrelated list hit |
| Upstream branches | 2026-10-26 | file contents on main, branch_10x and branch_10_6 | all still return (short) blockCount; |
| Upstream branches (recheck) | 2026-11-01 | file 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; |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (standalone and gradle); passes with the fix |
| 10.6.0 | affected | minimal repro fails with assertions on and off |
| 10.0.0-10.5.2 (12 releases) | affected | the 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) | affected | the 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 table | the repro does not compile (no lucene80.IndexedDISI); a 7.x baseline takes the same block walk by design |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Z3 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, high | Its own two-document boundary test: block 32,765 gives 32,767, block 32,766 gives -32,768, with assertions on and off. |
| independent reproducer | real | Its 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, high | Rated 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 | real | The arguments limit severity, not validity; its boundary test fails on main and 10.5.2. |
| adjudication | confirmed, high | Re-ran the independent reproduction's two tests and the finder's repro with new seeds: fail as claimed. |
| re-verification | still reproduces | Main ea57360912ed (assertions on and off), a9779ef3c193 (assertions on) and 645d4fe14a20 (assertions on and off); 10.6.0 (on and off). |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 513 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,294 words
- 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 (mainea57360912ed, 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.
- 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-550lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesConsumer.java:166-174, 461-470; Lucene90DocValuesProducer.java:388, 420lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90NormsConsumer.java:102-119; Lucene90NormsProducer.java:223lucene/core/src/java/org/apache/lucene/codecs/lucene95/OrdToDocDISIReaderConfiguration.java:76-93, 129lucene/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-319lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesFormat.java:65-66lucene/CHANGES.txt:6877 (LUCENE-8585)
- Suggested fix
lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene80/IndexedDISI.java:283
- TestIndexedDISIJumpTableCount.java (one test):
repro/LFV-169/src/org/apache/lucene/codecs/lucene90/TestIndexedDISIJumpTableCount.java(39 lines)
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.
assets/LFV-169/bug.png(513 KB)
