All findings · Lucene finding · LFV-185 · pending-positions-int-overflow

Skipped positions are counted in an int: once one term's pending occurrences in a postings block (the skipped documents plus the current one) pass Integer.MAX_VALUE, nextPosition() returns an earlier document's positions

When a postings block's skipped documents plus the current one hold over 2,147,483,647 occurrences of a term, the reader's int skip count wraps and returns an earlier document's positions: PhraseQuery f:"v t" finds 0 hits, not 1. It needs huge documents, such as two with a billion repeats each.

Summary

Priority
Low, rank 971Phrase 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
silent wrong results
Since
10.1.0 for today's code (#13968, 2024-11-01); the int counter dates from 2.1.0 (2006)introduced 2024-11-01 · about 2 years
Affected
2.1.0-9.11.1 and 10.1.0-10.6.0, main; not 9.12.x or 10.0.0
Status
documented
Found by
non-formal: code reading while modelling the reader, then an executed test

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

The finding in 30 seconds

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.

Background: how positions are read lazily

  1. 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.
  2. 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.
  3. 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.
Diagram of the bug: one postings block of term t holds doc 0 (2^30 positions), doc 1 (2^30 positions) and doc 2 (1 position). PhraseQuery "v t" jumps to doc 2 without reading the positions of docs 0 and 1; the reader adds the frequencies 1,073,741,824 + 1,073,741,824 + 1 = 2,147,483,649 in an int, which wraps to -2,147,483,647, so it skips nothing and returns doc 0's first position, 0 instead of 1, and the query finds 0 hits. With long counters the skip lands on doc 2's position 1.
How the frequencies of the two skipped documents and doc 2 add up past 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.

The finding, step by step

  1. Index three documents into a text field with the default codec and a default IndexWriterConfig: docs 0 and 1 are the token t repeated 2^30 = 1,073,741,824 times, doc 2 is v t. forceMerge(1) puts all three in one postings block of t.
  2. Run PhraseQuery f:"v t". Only doc 2 contains v, 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.
  3. You expect 1 hit: in doc 2, t is at position 1, right after v. Lucene 9.12 and 10.0 return it.
  4. 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.
  5. The query returns 0 hits and no error (assertions off). With assertions on, the reader's own assert posPendingCount > 0 fails. 10.1.0 to 10.6.0 and main are affected; the int dates back to Lucene 2.1.

Who is affected

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 bug, step by step (main 645d4fe14a20, Lucene104PostingsReader.java)

  1. 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).
  2. The catch-up. On the first nextPosition() of a document (:1299-1301), accumulatePendingPositions() (:1263-1274) runs posPendingCount += sumOverRange(freqBuffer, posDocBufferUpto, docBufferUpto): the frequencies of every document of the doc buffer whose positions were not read, plus the current one. It then asserts posPendingCount > 0 (:1268) and calls skipPositions(freq) only if (posPendingCount > freq) (:1270).
  3. All int. sumOverRange sums into int res (:270-276), and skipPositions computes int toSkip = posPendingCount - freq (:1133). In the repro, 2,147,483,649 wraps to -2,147,483,647 inside sumOverRange.
  4. 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).
  5. Exact boundary (independent checkers, executed): a sum of exactly Integer.MAX_VALUE reads correctly and Integer.MAX_VALUE + 1 does not; 2^31 - 16 is correct and 2^31 + 48 wrong.
  6. 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.

The numbers in the repro

doctext of ffreq of tpositions of t
0t x 2^301,073,741,8240 .. 1,073,741,823
1t x 2^301,073,741,8240 .. 1,073,741,823
2v t11

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#nextPosition on the default codec: PhraseQuery (executed), MultiPhraseQuery, span and interval queries, highlighters and payload consumers (code reading). Positions are indexed by default for TextField. Old 10.1-10.3 segments are read by the backward-codecs Lucene101/103 readers, which have the same int code.
  • Defaults. A default IndexWriterConfig (16 MB buffer, TieredMergePolicy), the default codec and forceMerge(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 of IndexWriter.MAX_POSITION, which limits position values: stacked occurrences (position increment 0) get around it, and the per-field token count would allow Integer.MAX_VALUE tokens. 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 with Integer.MAX_VALUE occurrences of one term fails while indexing with ArithmeticException: integer overflow at ByteBlockPool.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 AssertionError from that nextPosition() 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.

Minimal repro

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.

TestPhraseQueryPendingPositionsOverflow.java (one test, public API)74 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

import 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;
      }
    };
  }
}
Command
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, AssertionError at Lucene104PostingsReader.java:1268.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0; 10.0.0 passes
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

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

Why it looks unintended

  • PostingsEnum#nextPosition returns the current document's next position; only calling it more than freq() 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 (totalTermFreq is a long).
  • The reader asserts posPendingCount > 0 itself, and the 9.12/10.0 reader, with a long, 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, and PhraseQuery itself relies on it.
  • The documented limits are per document. IndexingChain rejects a position above IndexWriter.MAX_POSITION (IndexingChain.java:2021-2028) and a field length that overflows an int, "too many tokens for field" (Math.addExact, :2054-2064). Each repro document has 2^30 tokens. Test2BPositions indexes "> Integer.MAX_VALUE positions" for one term (Test2BPositions.java:35), and the Lucene104 format's "Limitations" section names only int document 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 toSkip and a long sumOverRange (backward-codecs Lucene912PostingsReader.java:302, :629, :939) and reads the same index correctly (executed on 9.12.0, 9.12.3 and 10.0.0). #13968 returned to int; 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.

Suggested fix

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 become long. No benchmark was run.
  • The backward-codecs Lucene90 and Lucene99 readers have the same int counters in three enums each (with a long[] 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.* with TestLucene104PostingsFormat, 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.* and lucene103.*: 106 tests, 0 failures, 6 skipped; queries spans.* and intervals.*: 349 tests, 0 failures, 2 skipped.
  • :lucene:core:check and :lucene:backward-codecs:check with -x test pass (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.

Keep sumOverRange for payloads and add a long frequency sum (not run)

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.

Detect the overflow and throw (not recommended)

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.

Document or enforce a limit at index time (not recommended)

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.

Downstream note

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.

How it was found

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 sumOverRange and int posPendingCount against long.
  • 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 and LogDocMergePolicy.
  • An independent verifier then wrote its own JUnit with a default IndexWriterConfig, which the one-test repro here is cut down from.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26the 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 PRs2026-10-26; re-checked 2026-10-31changed files of all 413 open PRs; diffs of those touching a postings reader; open PRs touching Lucene104PostingsReadernone changes the counter types
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2620 tool queries and direct searches for posPendingCount, accumulatePendingPositions, sumOverRange, Test2BPositionsno report; a 2020 java-user thread on a different nextPosition assertion
JIRA archive2026-10-264 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; web2026-10-26issue and code search for posPendingCount and accumulatePendingPositions; 6 web queriesno report
GitHub re-check2026-10-31posPendingCount, 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)

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test: 0 hits with assertions off, AssertionError with assertions on (gradle and the standalone runner)
10.6.0 affectedthe minimal test: 0 hits instead of 1 (assertions off)
10.5.2, 10.1.0 affectedthe checker's test: positions [1, 2, 3] instead of [101, 102, 103], phrase count 0
10.0.0 not affectedthe minimal test passes; the checker's test reads all positions correctly (Lucene912 reader, long)
9.12.0, 9.12.3 not affectedthe checker's test passes (Lucene912 reader, long)
9.11.1 raw positionspositions [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 affectedthe finder's program: first position of doc 1 is 0, expected 1
4.10.4, 4.0.0, 3.6.2 affectedvariants for the old APIs: first position 0 instead of 5; small controls pass
fix on 645d4fe14a20 not affectedthe minimal test passes with assertions on and off; the whole core suite (8,898 tests) passes

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateTwo 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, highIts 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 realIts 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, highReading only: intended argument weak; the SIMD motivation of #13968 does not cover a scalar counter.
Intended-behaviour devil's advocate realNo documented limit across documents; the scale argument does not make it intended.
adjudication confirmed, highRe-ran the independent reproduction's test with seed 5EED1234, assertions off: the large case fails, the control passes.
re-verification still reproducesSame 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 validatedThe 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 fixedIndependent 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.

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

Finding record

LFV-185, snapshot of 2026-11-06

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 main ea57360912ed, where the reader file is identical. All runs on Linux aarch64 with OpenJDK 25.0.3. Upstream main 2f424fd428da (2026-10-30) has the same code.

Lucene and upstream sources

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:211
  • lucene/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:14734
  • lucene/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)

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

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.