The reader reads positions lazily: for documents a query passes over, it only adds their frequencies to an int count of positions still to skip. Past 2,147,483,647 that count turns negative, and the skip is silently dropped.
- For each term, the postings store every document's frequency (how often the term occurs in it) and then its positions, all documents one after another in one position stream.
- A phrase query reads positions only for documents that contain all its terms. For documents it passes over, the reader does not decode anything: it adds their frequencies to a count of positions it is behind.
- On the next
nextPosition(), the reader skips that many positions and reads on. When it moves to the next 256-document block, it normally resets the count from the block's skip data.
Integer.MAX_VALUE in an int, the skip is dropped, and doc 2 reads doc 0's position 0, so the phrase is missed. Select the still to open it at full size. Generated with AI, checked against the code.- Index three documents into a text field with the default codec and a default
IndexWriterConfig: docs 0 and 1 are the tokentrepeated 2^30 = 1,073,741,824 times, doc 2 isv t.forceMerge(1)puts all three in one postings block oft. - Run
PhraseQuery f:"v t". Only doc 2 containsv, so the query jumps to doc 2 and never reads the positions of docs 0 and 1; the reader only notes how many positions it must skip. - You expect 1 hit: in doc 2,
tis at position 1, right afterv. Lucene 9.12 and 10.0 return it. - Instead the reader adds the frequencies in an
int: 1,073,741,824 + 1,073,741,824 + 1 = 2,147,483,649 wraps to -2,147,483,647. A negative count means "skip nothing", so doc 2 gets doc 0's first position, 0 instead of 1. - The query returns 0 hits and no error (assertions off). With assertions on, the reader's own
assert posPendingCount > 0fails. 10.1.0 to 10.6.0 and main are affected; theintdates back to Lucene 2.1.
Anyone reading positions on the default codec (phrase, span and interval queries, positional highlighting), once one term's occurrences in the skipped documents of a 256-document postings block, plus the current one, pass Integer.MAX_VALUE. Defaults suffice, but it takes huge documents, such as two that each repeat one term about a billion times, so it is contrived.
Mechanism, terms and numbers647 words
Lucene104PostingsReader.BlockPostingsEnum reads positions lazily. When a consumer passes documents without reading their positions, the reader only adds their frequencies to posPendingCount, the number of positions it is "behind", and skips that many on the next nextPosition(). That count, the helper that sums the frequencies (sumOverRange) and the skip distance (toSkip) are all int. When the skipped documents of one postings block plus the current one hold more than 2,147,483,647 occurrences of the term, the sum wraps negative, the skip is not done, and nextPosition() returns positions from the start of an earlier document.
With assertions off this is silent: in the repro PhraseQuery f:"v t" counts 0 instead of 1. With assertions on, assert posPendingCount > 0 fails. A default IndexWriterConfig and the default codec suffice, and every document is within Lucene's per-document limits, because the trigger is cumulative across documents. It needs huge documents, for example two that each repeat one term about a billion times, so the priority is low. The Lucene912 reader of 9.12.x and 10.0.0 used long and is correct; making the three values long again fixes it.
- The count.
private int posPendingCount;, commented "how many positions "behind" we are; nextPosition must skip these to "catch up"" (:381-383); set to 0 when the enum is reset to a term (:538). - The catch-up. On the first
nextPosition()of a document (:1299-1301),accumulatePendingPositions()(:1263-1274) runsposPendingCount += sumOverRange(freqBuffer, posDocBufferUpto, docBufferUpto): the frequencies of every document of the doc buffer whose positions were not read, plus the current one. It then assertsposPendingCount > 0(:1268) and callsskipPositions(freq)onlyif (posPendingCount > freq)(:1270). - All
int.sumOverRangesums intoint res(:270-276), andskipPositionscomputesint toSkip = posPendingCount - freq(:1133). In the repro, 2,147,483,649 wraps to -2,147,483,647 insidesumOverRange. - No skip. A negative count is never
> freq, so decoding continues where the position stream stands: the start of doc 0's positions (:1306-1310). Payloads and offsets are read in lockstep from the same point (:1276-1290, code reading). - Exact boundary (independent checkers, executed): a sum of exactly
Integer.MAX_VALUEreads correctly andInteger.MAX_VALUE + 1does not; 2^31 - 16 is correct and 2^31 + 48 wrong. - One block. Moving to the next level-0 block while the position stream is behind seeks forward and resets the count from skip data (
doMoveToNextLevel0Block,:721-736;seekPosData,:799-815). Only when that block's positions were already decoded does it add the remaining frequencies to the count instead (:735,:814). So from 10.1 on, on the lazy path the repro takes, the overflowing sum must in practice come from one block: 256 documents containing the term in Lucene104 (since 10.4), 128 in Lucene101/103 (code reading). The reset normally also ends the damage at the end of the block; the two carry-over branches were not exercised, so this is not a guarantee.
| doc | text of f | freq of t | positions of t |
|---|---|---|---|
| 0 | t x 2^30 | 1,073,741,824 | 0 .. 1,073,741,823 |
| 1 | t x 2^30 | 1,073,741,824 | 0 .. 1,073,741,823 |
| 2 | v t | 1 | 1 |
PhraseQuery f:"v t" lands on doc 2 directly. Sum of frequencies: 2,147,483,649, one int wrap: -2,147,483,647. The reader returns 0 for t in doc 2, the phrase needs 1, so the query counts 0. A direct-read probe on this exact index (fact check, executed) printed these values: the int running sums 1,073,741,824, -2,147,483,648, -2,147,483,647 and nextPosition() = 0 on doc 2. On main ea57360912ed the checkers' own variants printed the substituted positions directly: "first position of t in doc 1: 0 (expected 1)" and "expected first positions [101, 102, 103], got [1, 2, 3]".
Exposure and likelihood367 words
- Who can be hit. Every consumer of
PostingsEnum#nextPositionon the default codec:PhraseQuery(executed),MultiPhraseQuery, span and interval queries, highlighters and payload consumers (code reading). Positions are indexed by default forTextField. Old 10.1-10.3 segments are read by the backward-codecs Lucene101/103 readers, which have the sameintcode. - Defaults. A default
IndexWriterConfig(16 MB buffer,TieredMergePolicy), the default codec andforceMerge(1)reproduce it. No expert setting is needed. - Scale. More than
Integer.MAX_VALUE(2^31 - 1) occurrences of one term among the skipped documents of one postings block plus the current one (a pending sum of exactly 2^31 already fails): for example two documents of 2^30 occurrences (about 1 GiB of indexing buffer each; the test needs a 4 GB heap and about 2.5-5 minutes), or about 8.4 million occurrences per document across a full 256-document block (code reading). Positions forbid custom term frequencies, so every occurrence is a real token. One document alone cannot reach it, but not because ofIndexWriter.MAX_POSITION, which limits position values: stacked occurrences (position increment 0) get around it, and the per-field token count would allowInteger.MAX_VALUEtokens. What stops one document is the writer's in-memory postings pool, which buffers at least one byte per occurrence and is capped at 2^31 bytes: a document withInteger.MAX_VALUEoccurrences of one term fails while indexing withArithmeticException: integer overflowatByteBlockPool.nextBuffer(ByteBlockPool.java:199; executed by the fact check). So two large documents are the smallest input. - What goes wrong. Assertions off: silent wrong positions for the documents after the trigger documents in that block (executed: a phrase false negative). Code reading only, not executed: false positives when the substituted positions happen to line up; offsets and payloads taken from the earlier, possibly deleted, document; and a merge that skips deleted documents without reading their positions could write wrong positions into the merged segment. Assertions on: an
AssertionErrorfrom thatnextPosition()call. - Bounds (code reading): reads stay inside the same term's position data, and the block reset normally ends the damage at the end of the block (the carry-over branches were not exercised); no hang and no memory unsafety, and with the skip dropped the reader does less work, not more.
One JUnit test on the public API: two documents of 2^30 t tokens and one v t document, a default IndexWriterConfig with the default codec pinned, forceMerge(1) and one PhraseQuery count. Deterministic and single-threaded, but heavy by nature: about 2.5-5 minutes and a 4 GB heap.
/*
* 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 java.io.IOException;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
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.store.FSDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestPhraseQueryPendingPositionsOverflow extends LuceneTestCase {
public void testSkippedFreqsPastIntMax() throws IOException {
try (Directory dir = FSDirectory.open(createTempDir())) {
IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
try (IndexWriter w = new IndexWriter(dir, iwc)) {
for (int i = 0; i < 2; i++) {
Document doc = new Document();
doc.add(new TextField("f", repeatT(1 << 30)));
w.addDocument(doc);
}
Document doc = new Document();
doc.add(new TextField("f", "v t", Field.Store.NO));
w.addDocument(doc);
w.forceMerge(1);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
assertEquals(
"hits of f:\"v t\" (only the last doc matches; the 3 freqs of t sum to 2^31 + 1)",
1,
new IndexSearcher(reader).count(new PhraseQuery("f", "v", "t")));
}
}
}
private static TokenStream repeatT(int count) {
return new TokenStream() {
private final CharTermAttribute term = addAttribute(CharTermAttribute.class);
private int left = count;
@Override
public boolean incrementToken() {
if (left-- == 0) {
return false;
}
clearAttributes();
term.append('t');
return true;
}
};
}
}
cd $LUCENE # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestPhraseQueryPendingPositionsOverflow.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestPhraseQueryPendingPositionsOverflow -Ptests.heapsize=4g
# assertions off (the silent wrong count): add -Ptests.asserts=false
- Expected
PhraseQuery f:"v t"counts 1 hit, doc 2, as on Lucene 9.12.x and 10.0.0.- Observed
- 0 hits with assertions off (
expected:<1> but was:<0>); with assertions on,AssertionErroratLucene104PostingsReader.java:1268.
More files, environment, full output294 words
The test sits in org.apache.lucene.search and uses only public API. It pins the default codec because LuceneTestCase randomizes it, and writes to an FSDirectory in a temporary directory (about 1-2 GB of disk while it runs). Each 2^30-token document flushes to its own segment under the default 16 MB buffer, and the forced merge orders segments by size, largest first, so v t ends up last; the test asserts only the phrase count, not the segment count, that order or the raw positions (probes on the same index printed those: one leaf, doc order 2^30, 2^30, v t, nextPosition() = 0 on doc 2). Two large documents are the smallest input: one document with Integer.MAX_VALUE occurrences of one term already fails while indexing, because the writer's postings pool, at least one byte per occurrence, is capped at 2^31 bytes (ByteBlockPool.java:199; IndexWriter.MAX_POSITION limits only position values, which stacked tokens get around).
As a regression test it is the same test; it fails on stock and passes with the fix. Its scale matches the @Monster tests, like Test2BPositions (which run only with -Ptests.monster=true). All runs were on Linux aarch64 with OpenJDK 25.0.3; the mechanism is plain int arithmetic.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0; 10.0.0 passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# gradle, stock 645d4fe14a20, seed DEADBEEF, assertions on
TestPhraseQueryPendingPositionsOverflow > testSkippedFreqsPastIntMax FAILED
java.lang.AssertionError
at org.apache.lucene.codecs.lucene104.Lucene104PostingsReader$BlockPostingsEnum.accumulatePendingPositions(Lucene104PostingsReader.java:1268)
at org.apache.lucene.codecs.lucene104.Lucene104PostingsReader$BlockPostingsEnum.nextPosition(Lucene104PostingsReader.java:1301)
at org.apache.lucene.search.ExactPhraseMatcher.advancePosition(ExactPhraseMatcher.java:141)
# assertions off (-Ptests.asserts=false)
TestPhraseQueryPendingPositionsOverflow > testSkippedFreqsPastIntMax FAILED
java.lang.AssertionError: hits of f:"v t" (only the last doc matches; the 3 freqs of t sum to 2^31 + 1) expected:<1> but was:<0>
# with the fix, assertions on and off
:lucene:core:test (SUCCESS): 1 test
# release jars, assertions off: 10.6.0 fails (expected:<1> but was:<0>), 10.0.0 passesPostingsEnum#nextPositionreturns the current document's next position; only calling it more thanfreq()times is undefined.- Lucene's limits are per document, and each repro document stays below them; no documentation we found caps the sum across documents (
totalTermFreqis along). - The reader asserts
posPendingCount > 0itself, and the 9.12/10.0 reader, with along, returns the hit.
The full argument206 words
- The contract. "Returns the next position, or -1 if positions were not indexed. Calling this more than
freq()times is undefined." (PostingsEnum.java:85-88). The repro calls it once for doc 2. Skipping other documents' positions is the normal lazy pattern, andPhraseQueryitself relies on it. - The documented limits are per document.
IndexingChainrejects a position aboveIndexWriter.MAX_POSITION(IndexingChain.java:2021-2028) and a field length that overflows anint, "too many tokens for field" (Math.addExact,:2054-2064). Each repro document has 2^30 tokens.Test2BPositionsindexes "> Integer.MAX_VALUE positions" for one term (Test2BPositions.java:35), and the Lucene104 format's "Limitations" section names onlyintdocument numbers (package-info.java:426-436). - The code's own assumption.
assert posPendingCount > 0(:1268) says the count is never negative; it fires on this input. - History. The Lucene912 reader (9.12.x, 10.0.0, #13585) declared
long posPendingCount,long toSkipand alongsumOverRange(backward-codecsLucene912PostingsReader.java:302,:629,:939) and reads the same index correctly (executed on 9.12.0, 9.12.3 and 10.0.0). #13968 returned toint; its description and reviews never mention overflow. - Precedent. LUCENE-4468 / #5534, "Fix rareish integer overflows in Lucene41 postings format" (
CHANGES.txt:14734), fixed the same class of bug in file-pointer fields.
The devil's-advocate check115 words
The strongest case for "intended": the scale is absurd, since nobody puts 2^31 occurrences of one term into 256 documents, and the int[] move in #13968 was a deliberate performance choice for more SIMD lanes.
Why it fails: the scale explains the low priority but is not a documented limit, and every input passes Lucene's own validation with default settings. The SIMD motivation covers the int[] buffers, not a scalar counter or a scalar sum, and 9.12.x and 10.0.0 shipped the long version. The checkers that gave a strength rating (the independent reproducer and reviews by another model family, OpenAI GPT-5.6) rated the intended-behaviour argument weak; the devil's advocate also rejected it, without a rating.
Recommended Make the count, the frequency sum and the skip distance long
Make posPendingCount, the frequency sum sumOverRange and toSkip long, as the 9.12/10.0 reader did, and do the same in the backward-codecs Lucene101 and Lucene103 readers. The repro then passes with assertions on and off, and so do the whole core test suite (8,898 tests), those backward-codecs packages, the span and interval tests and check.
- private static int sumOverRange(int[] arr, int start, int end) {
- int res = 0;
+ private static long sumOverRange(int[] arr, int start, int end) {
+ long res = 0;
- private int posPendingCount;
+ private long posPendingCount;
- int toSkip = posPendingCount - freq;
+ long toSkip = posPendingCount - freq;
- int end = posBufferUpto + toSkip;
+ int end = posBufferUpto + (int) toSkip;
- payloadByteUpto = sumOverRange(payloadLengthBuffer, 0, toSkip);
+ payloadByteUpto = (int) sumOverRange(payloadLengthBuffer, 0, (int) toSkip);
- posBufferUpto = toSkip;
+ posBufferUpto = (int) toSkip;All options and trade-offs4 options ยท 495 words
The hunt's fix, extended to the two backward-codecs readers that share the code, was run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF).
Make the count, the frequency sum and the skip distance long (validated by the checks) (recommended)
In Lucene104PostingsReader (:270-276, :383, :1131-1174) and identically in the backward-codecs Lucene101PostingsReader and Lucene103PostingsReader: the types the Lucene912 reader used. The casts sit where toSkip is already below the 256-entry block (toSkip < leftInBlock, or after the while (toSkip >= BLOCK_SIZE) loop); payloadByteUpto += sumOverRange(...) keeps compiling through compound-assignment narrowing, and that sum stays within one block.
- private static int sumOverRange(int[] arr, int start, int end) {
- int res = 0;
+ private static long sumOverRange(int[] arr, int start, int end) {
+ long res = 0;
- private int posPendingCount;
+ private long posPendingCount;
- int toSkip = posPendingCount - freq;
+ long toSkip = posPendingCount - freq;
- int end = posBufferUpto + toSkip;
+ int end = posBufferUpto + (int) toSkip;
- payloadByteUpto = sumOverRange(payloadLengthBuffer, 0, toSkip);
+ payloadByteUpto = (int) sumOverRange(payloadLengthBuffer, 0, (int) toSkip);
- posBufferUpto = toSkip;
+ posBufferUpto = (int) toSkip;Trade-offs
- The
int[]buffers that motivated #13968 are untouched; one scalar field and one reduction over at most 256 ints becomelong. No benchmark was run. - The backward-codecs Lucene90 and Lucene99 readers have the same
intcounters in three enums each (with along[]freq buffer); the analogous change there was not written or run.
What the checks validated
- Stock: the repro fails with assertions on and off.
- Fix: the repro passes with assertions on and off.
- The closest core suites (
codecs.lucene104.*withTestLucene104PostingsFormat, and the positions, payloads, offsets, skip-list and phrase tests): 24 classes, 278 tests, 0 failures, 7 skipped. - The whole core suite with the repro in the tree: 749 classes, 8,898 tests, 0 failures, 322 skipped.
- backward-codecs
lucene101.*andlucene103.*: 106 tests, 0 failures, 6 skipped; queriesspans.*andintervals.*: 349 tests, 0 failures, 2 skipped. :lucene:core:checkand:lucene:backward-codecs:checkwith-x testpass (google-java-format, ecjLint, forbiddenApis, checksums).- Earlier, shadowed onto the stock jars, the Lucene104 change passed the finder's repro, the refute reviewer's own check and the minimal test.
A long sumFreqs(int[], int, int) for the three posPendingCount += sites (:735, :814, :1265), with posPendingCount and toSkip long.
Trade-offs
Same effect; a slightly larger diff that leaves the payload-length path unchanged.
Math.addExact on the count.
Trade-offs
Turns silent wrong results into an exception on an index that Lucene accepted and that a long reads correctly.
Cap a term's occurrences per postings block.
Trade-offs
The writer cannot know which documents a reader will skip, and a cap would reject indexes that 9.12.x and 10.0.0 read correctly.
Not a Lucene security issue; downstream, an application that indexes untrusted documents without size limits would let one writer of such documents change phrase results for neighbouring documents in the same postings block.
Non-formal: while modelling the Lucene104 postings reader for a differential test harness, an AI tool read its position bookkeeping and saw that the skipped-position count is an int where the 9.12 reader used a long. A test on the stock jar with two documents of about 2^30 occurrences confirmed it; independent checkers then reproduced it with their own tests.
The method in full118 words
Method: non-formal (code reading plus an executed test; no model or solver produced the signal), a hunt unit on the Lucene104 postings reader.
- The unit built a postings differential harness (0 mismatches at ordinary frequencies, 8 reader mutants killed). While modelling the reader for it, the finder compared the position bookkeeping with the Lucene912 reader and noticed
int sumOverRangeandint posPendingCountagainstlong. - A
main()on the stock main jar with two documents of 2^30+1 and 2^30+2 occurrences confirmed it with assertions on and off; it used an expert RAM setting andLogDocMergePolicy. - An independent verifier then wrote its own JUnit with a default
IndexWriterConfig, which the one-test repro here is cut down from.
No prior report: 93 queries over GitHub issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, downstream projects and the web, plus the changed files of all 413 open PRs (2026-10-26); re-checked on GitHub on 2026-10-31.
Searches6 searches
Closest items: #15324 (closed) trips the same assert posPendingCount > 0, but because DisjunctionScorer used stale verified matches on a NO_MORE_DOCS document under MaxScoreBulkScorer (fixed there by #15380), with small frequencies; #13968 and #13585 changed the counter type without discussing overflow; LUCENE-4468 / #5534 is the precedent, the same class of overflow in file-pointer fields.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | the hunt's search tool (20 issue/PR and 3 commit queries) plus 14 manual searches: posPendingCount, accumulatePendingPositions, sumOverRange, skipPositions, the reader class names, "integer overflow" positions, Test2BPositions, "Move postings back to int" | no report; #15324 (same assertion, different cause), #13968, #13585, #14032, #5534 |
| Open PRs | 2026-10-26; re-checked 2026-10-31 | changed files of all 413 open PRs; diffs of those touching a postings reader; open PRs touching Lucene104PostingsReader | none changes the counter types |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | 20 tool queries and direct searches for posPendingCount, accumulatePendingPositions, sumOverRange, Test2BPositions | no report; a 2020 java-user thread on a different nextPosition assertion |
| JIRA archive | 2026-10-26 | 4 tool queries and direct JQL (posPendingCount, overflow and positions, totalTermFreq and overflow, Test2BPositions) | LUCENE-2878 (a branch test failure) and LUCENE-4468 (precedent) only |
| Elasticsearch, OpenSearch, Solr; web | 2026-10-26 | issue and code search for posPendingCount and accumulatePendingPositions; 6 web queries | no report |
| GitHub re-check | 2026-10-31 | posPendingCount, sumOverRange, accumulatePendingPositions, "nextPosition overflow", "positions integer overflow phrase", "Lucene104PostingsReader overflow" | only the items above (the fact check repeated these searches read-only on the same day) |
Today's code came with #13968 (10.1.0, 2024-11-01), which returned to an int after the 9.12/10.0 reader's long; the int count itself dates from Lucene 2.1 (2006). Failing runs on releases from 3.6.2 to 10.6.0 and on main; 9.12.0, 9.12.3 and 10.0.0 pass. Not fixed upstream.
Versions tested9 rows
Current code: #13968 "Move postings back to int[] to take advantage of having more lanes per vector." (main cfdd20f5bc8, branch_10x 8ae03d66ad1, 2024-11-01, first release 10.1.0) added the Lucene101 reader with int posPendingCount and int toSkip where the Lucene912 reader had long; #14033 (6c48b404cd4, 2024-12-04, also 10.1.0) made sumOverRange return int. Lucene103 and Lucene104 copied the code.
Older: the default positional readers kept the pending count in an int from LUCENE-687 (c0d330c0417, 2006-10-30, first in 2.1.0) through 9.11.1. Only #13585 (b4a8810b7ae, the Lucene912 reader of 9.12.0-9.12.3 and 10.0.0) used long; no PR discussed overflow, so whether that was meant as a guard is not established.
Trigger by version: in 2.1-3.6 only the documents passed over count (on 3.6.2, one skipped 2^30+1 document is fine, two are not); from 4.0 the current document is added too; in 4.1-9.11 only an advance() skip resets the count, so the documents need not share a block (code reading); from 10.1 a block move resets it. On 9.11.1 the raw positions are wrong but the checker's phrase query matched by coincidence: the Lucene99 reader re-checks the count on every nextPosition(), and the wrapped value came back around for those numbers.
Not fixed upstream (2026-10-31): main 2f424fd428da, branch_11x, branch_10_6 and the 10.6.0 tag still use int. On main the backward-codecs Lucene90, Lucene99, Lucene101 and Lucene103 readers have the same counters (code reading).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test: 0 hits with assertions off, AssertionError with assertions on (gradle and the standalone runner) |
| 10.6.0 | affected | the minimal test: 0 hits instead of 1 (assertions off) |
| 10.5.2, 10.1.0 | affected | the checker's test: positions [1, 2, 3] instead of [101, 102, 103], phrase count 0 |
| 10.0.0 | not affected | the minimal test passes; the checker's test reads all positions correctly (Lucene912 reader, long) |
| 9.12.0, 9.12.3 | not affected | the checker's test passes (Lucene912 reader, long) |
| 9.11.1 | raw positions | positions [1, 2, 3] instead of [101, 102, 103]; that test's phrase query matched by coincidence |
| 9.0.0, 8.11.4, 6.6.6 | affected | the finder's program: first position of doc 1 is 0, expected 1 |
| 4.10.4, 4.0.0, 3.6.2 | affected | variants for the old APIs: first position 0 instead of 5; small controls pass |
fix on 645d4fe14a20 | not affected | the minimal test passes with assertions on and off; the whole core suite (8,898 tests) passes |
Confirmed with high confidence by the study's judge after an independent reproducer, an intended-behaviour devil's advocate and reviews by another model family (OpenAI GPT-5.6), with no dissent; re-run on 645d4fe14a20 and the 10.6.0 jars, and the fix validated through gradle.
Checks and verdicts314 words
Each checker that executed wrote its own test from the claim; the adjudication re-ran the independent reproducer's test.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Two documents of 2^30+1 and 2^30+2 occurrences: first position of doc 1 is 0 instead of 1 with assertions off, AssertionError with assertions on; 10.1.0 and 9.11.1 fail, 10.0.0 passes; the long fix passes. |
| refute review (OpenAI GPT-5.6) | real, high | Its own public-API check is correct at a sum of exactly Integer.MAX_VALUE (position 17) and wrong at +1 (position 0); passes with the fix shadowed. |
| Independent reproducer | real | Its own JUnit with a default IndexWriterConfig fails on main, 10.5.2, 10.1.0 and 9.11.1 and passes on 9.12.3; a small control passes. |
| Works-as-designed review (OpenAI GPT-5.6) | real, high | Reading only: intended argument weak; the SIMD motivation of #13968 does not cover a scalar counter. |
| Intended-behaviour devil's advocate | real | No documented limit across documents; the scale argument does not make it intended. |
| adjudication | confirmed, high | Re-ran the independent reproduction's test with seed 5EED1234, assertions off: the large case fails, the control passes. |
| re-verification | still reproduces | Same failures on main a9779ef3c193 (2026-10-26); the one-test repro fails on 645d4fe14a20 and passes with the fix shadowed (2026-10-29). |
| this doc (2026-10-31) | real, fix validated | The minimal test on the 10.6.0 (fail) and 10.0.0 (pass) jars, and the fix through gradle. |
| fact checks (2026-10-31) | real, one wording error fixed | Independent fact checks of this doc, one by another model family (OpenAI GPT-5.6). The first re-ran the minimal test on main (-ea, -da), on the 10.6.0 jars and with the fix, and ran a direct-read probe and a one-document probe; both confirmed the mechanism and the numbers. They corrected why one document cannot trigger the bug, the boundary wording (the current document counts too) and some overclaims, all fixed here. |
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 486 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,373 words
- Registry title
- Postings readers count skipped positions in an int, so once the skipped occurrences of one term within a postings block pass Integer.MAX_VALUE, nextPosition() returns an earlier document's positions and phrase queries silently miss matches
- Area
- codecs / postings reader (Lucene104 BlockPostingsEnum position bookkeeping; also the Lucene101, Lucene103, Lucene99 and Lucene90 readers in backward-codecs)
- Priority
- Low, rank 971: Phrase positions go wrong once one term's skipped occurrences within a postings block pass 2^31, which needs documents repeating a term about a billion times.
- Impact class
- silent wrong results
- Impact note
- Hits positional search (PhraseQuery executed; span, interval, highlighting and payload consumers use the same nextPosition() path [read]) on a term with more than 2^31 occurrences among the documents of one postings block whose positions were skipped: with assertions off, a later document of that block gets an earlier document's positions, so matching is silently wrong (executed: a phrase false negative), and with assertions on 'assert posPendingCount > 0' fails the call; default codec and a default IndexWriterConfig suffice, and every release from 2.1.0 [read] / 3.6.2 [exec] to 10.6.0 is affected except 9.12.x and 10.0.0, but only with documents that repeat one term on the order of 2^30 times.
- Summary
- You index documents that each stay within Lucene's per-document limits but repeat one term about a billion times (the independent reproduction's test: one document with 2^30 - 16 occurrences of t, one with 2^30, then a normal document C with 'v' followed by 64 t's) and run PhraseQuery f:"v t", which only C matches. You expect the postings reader to return C's own positions (101, 102, 103, ...) and the query to find C. Instead the reader adds up the frequencies of the documents whose positions it skipped in an int, the sum 2,147,483,696 wraps negative, the skip is not done, and nextPosition() returns the first skipped document's positions (1, 2, 3), so the query finds 0 hits with assertions off (with assertions on, an internal assertion fails instead).
- Introduced
- cfdd20f5bc8387ba24653ca2ba15aa5be10d0ae0 on main and its branch_10x backport 8ae03d66ad1579d675e9b7618d598e545e95d5a2 (the commit the 10.x release tags contain), PR #13968 'Move postings back to int[] to take advantage of having more lanes per vector.' (author jpountz): the new Lucene101PostingsReader declared 'private int posPendingCount' and 'int toSkip = posPendingCount - freq' where the Lucene912 reader it replaced used long [read: git blame of Lucene104PostingsReader.java:383 and :1133 on main]; #14033 (6c48b404cd4, same release) then made sumOverRange return int, with the same behaviour. This re-introduced a long-standing defect: every default positional postings reader from LUCENE-687 (c0d330c04174, 2006, first in 2.1.0) through 9.11.1 kept the pending count in an int, and only #13585 (b4a8810b7ae, Lucene912, 9.12.0 and 10.0.0) used long; the PR texts and reviews of #13585 and #13968 never discuss overflow, so whether the long was meant as an overflow guard is not established [read] (2024-11-01 (#13968, main and branch_10x backport the same day); the int counter itself dates from 2006-10-30 (LUCENE-687))
- First affected version
- 10.1.0 for the current code (#13968) [exec]; the same int bookkeeping is in every release from 2.1.0 [read] (oldest executed: 3.6.2) through 9.11.1 [exec]; only 9.12.0-9.12.3 and 10.0.0 are unaffected [exec]
- Affected versions
- 2.1.0-9.11.1, 10.1.0-10.6.0 and main ea57360912ed. [exec, -da] fail: 3.6.2, 4.0.0, 4.10.4, 6.6.6, 8.11.4, 9.0.0, 9.11.1, 10.1.0, 10.5.2, 10.6.0 (the minimal test, 2026-10-31) and main (main also -ea); pass: 9.12.0, 9.12.3 and 10.0.0 (Lucene912 reader with long counters); small-scale controls pass on 3.6.2, 4.0.0 and 4.10.4. [read] 2.1.0-3.6.1, 4.1.0-4.10.3, 5.x, 7.x, 8.0-8.11.3, 9.1-9.11.0, 10.2.x, 10.3.x, 10.4.0-10.5.1, and on main the backward-codecs Lucene90, Lucene99, Lucene101 and Lucene103 readers (old segments); 2.0.0 and earlier skipped positions eagerly per document, so nothing accumulated [read, not executed]. On 9.11.1 the raw positions are wrong but the independent reproduction's PhraseQuery happens to match, because the Lucene99 reader re-checks the count on every nextPosition() and the wrapped value comes back around; from 10.1.0 on the PhraseQuery gives a false negative. Not fixed upstream: main and branch_10x (1309534296e3) still use int (read-only check, 2026-10-26).
- 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
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new: one scripted prior-report search of 55 queries (20 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA, 6 web; 572 hits, 0 high), the finder's (25 queries) and the intended-behaviour check's (29 queries, 517 hits, 0 high) runs, 14 manual GitHub searches including Elasticsearch, OpenSearch and Solr, direct mailing-list and JIRA queries, the changed-file lists of all 413 open apache/lucene PRs, the comments and reviews of #13968, #13585, #14032, #13631 and #14735, and the full git history; 20 hits read, none the same bug); not fixed: upstream main ea57360912ed and branch_10x 1309534296e3 still use int counters, the last commit to Lucene104PostingsReader.java (#16491) changes comments only, and no open PR changes the types; related, not reports of this bug: #15324 (closed; a comment shows the same 'assert posPendingCount > 0' firing, but from DisjunctionScorer reading positions after NO_MORE_DOCS, fixed by #15380), #13968 (re-introduced the int counter, no overflow discussion), #13585 (the Lucene912 long counters), LUCENE-4468 / #5534 (precedent: 'Fix rareish integer overflows in Lucene41 postings format', in file-pointer fields)
- Lucene commit tested
- main
645d4fe14a20(gradle and the standalone runner, assertions on and off; the fix through gradle in a scratch worktree of the same commit); the minimal test on the 10.6.0 jars (fails) and the 10.0.0 jars (passes); equivalent tests by the checkers on 10.5.2, 10.1.0, 9.12.3, 9.12.0, 9.11.1, 9.0.0, 8.11.4, 6.6.6, 4.10.4, 4.0.0 and 3.6.2 and on mainea57360912ed, where the reader file is identical. All runs on Linux aarch64 with OpenJDK 25.0.3. Upstream main2f424fd428da(2026-10-30) has the same code.
- Finding record
lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:270-276 (int sumOverRange), :381-383 (int posPendingCount), :721-736 (doMoveToNextLevel0Block: count reset or += sumOverRange at :735), :799-815 (seekPosData, += at :814), :1131-1174 (skipPositions, int toSkip at :1133), :1263-1274 (accumulatePendingPositions: += at :1265, assert at :1268, 'if (posPendingCount > freq)' at :1270), :1276-1290 (payloads and offsets read in lockstep), :1293-1318 (nextPosition)lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene103/Lucene103PostingsReader.java:266, :379, :1119; lucene101/Lucene101PostingsReader.java:264, :377, :1073; lucene99/Lucene99PostingsReader.java:603, :925 (and :1272, :1576 in its other enums); lucene90/Lucene90PostingsReader.java:605, :919 (and :1272, :1579) (the same int counters); lucene912/Lucene912PostingsReader.java:302, :629, :939 (long, unaffected)lucene/core/src/java/org/apache/lucene/index/PostingsEnum.java:85-88 (nextPosition: 'Returns the next position ... Calling this more than freq() times is undefined'); IndexingChain.java:1999-2008 (per-document field length via Math.addExact); IndexWriter.java:211 (MAX_POSITION = Integer.MAX_VALUE - 128); FreqProxTermsWriterPerField.java:208-218 (positions forbid custom term frequencies); codecs/lucene104/ForUtil.java:34 (BLOCK_SIZE = 256); codecs/lucene104/package-info.java:426-436 (Limitations: only int32 document numbers)lucene/core/src/test/org/apache/lucene/index/Test2BPositions.java:35 ('> Integer.MAX_VALUE positions'); lucene/CHANGES.txt:14701 (LUCENE-4468: 'Fix rareish integer overflows in Lucene41 postings format')
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:270-276 (at 645d4fe14a20) (int sumOverRange), :381-383 (int posPendingCount), :538 (reset), :721-736 and :799-815 (block resets), :1131-1174 (skipPositions; int toSkip at :1133), :1263-1274 (accumulatePendingPositions; assert at :1268), :1276-1290 (payloads and offsets), :1293-1318 (nextPosition)lucene/core/src/java/org/apache/lucene/codecs/lucene104/ForUtil.java:34 (at 645d4fe14a20) (BLOCK_SIZE = 256)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/FreqProxTermsWriterPerField.java:208-218 (at 645d4fe14a20) (positions forbid custom term frequencies); index/IndexWriter.java:211 (MAX_POSITION, a limit on position values); index/IndexingChain.java:2054-2064 (per-field token count, Math.addExact on an int); index/FreqProxTermsWriterPerField.java:84-86 (writeProx: at least one byte per occurrence); util/ByteBlockPool.java:199 (Math.addExact on the pool's int byte offset)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/PostingsEnum.java:85-88; (at 645d4fe14a20) index/IndexingChain.java:2021-2028, :2054-2064; index/IndexWriter.java:211lucene/core/src/test/org/apache/lucene/index/Test2BPositions.java:35; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/package-info.java:426-436; lucene/CHANGES.txt:14734lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene912/Lucene912PostingsReader.java:302, (at 645d4fe14a20) :629, :939
- Suggested fix
lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene101/Lucene101PostingsReader.java:264, (at 645d4fe14a20) :377, :1073; lucene103/Lucene103PostingsReader.java:266, :379, :1119; lucene99/Lucene99PostingsReader.java:603, :925, :1272, :1462, :1576, :1908; lucene90/Lucene90PostingsReader.java:605, :919, :1272, :1467, :1579, :1910; lucene912/Lucene912PostingsReader.java:302, :629, :939
- Prior reports
this brief's read-only gh searches on 2026-10-31
- History
a Lucene clone git show of releases/lucene/10.6.0, origin/branch_10_6, origin/branch_11x, origin/main 2f424fd428da: Lucene104PostingsReader.java int sumOverRange, posPendingCount, toSkip (2026-10-31)
- TestPhraseQueryPendingPositionsOverflow.java (one test, public API):
repro/LFV-185/TestPhraseQueryPendingPositionsOverflow.java(74 lines)
Made from the code at 645d4fe14a20 and the executed runs: every value on screen comes from a diagram probe on the repro's index, a recorded repro run or the code.
assets/LFV-185/bug.png(486 KB)
