All findings · Lucene finding · LFV-613 · bkd-heap-sort-buffer-int-overflow

A points sort budget of 2048 MB or more can make BKDWriter overflow its in-heap buffer size, so a points merge of more than 2 GiB that still fits the budget throws NegativeArraySizeException and closes the IndexWriter

With a custom points sort budget of 2048 MB or more, merging over 2 GiB of one point field that still fits the budget sizes one array in int arithmetic: NegativeArraySizeException, and the IndexWriter closes. The default 16 MB is safe; releases 8.1.0 to 10.6.0 are affected.

Summary

Priority
Low, rank 963A custom points sort budget of at least 2048 MB plus a points merge over 2 GiB closes the IndexWriter with NegativeArraySizeException, an extreme-scale opt-in case that is easier to reach than the giant-token case LFV-385.
Impact
crash
Since
8.1.0 (LUCENE-8699 and two commits of the same release, 2019)introduced 2019-02-20 · about 7 years
Affected
8.1.0 to 10.6.0 and main; 6.1.0 to 8.0.0 reject the budget up front, 6.0.0 only when sorting offline
Status
documented
Found by
non-formal: code reading of the BKD write path

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

BKDWriter keeps a field's points in one heap array when they fit the sort budget and spills them to disk otherwise. It turns the budget into a point count without capping it at the largest Java array, so with a budget of 2048 MB or more the array size bytesPerDoc x points can pass 2^31-1 and wrap negative.

Background: how points are sorted when a segment is written

  1. Point fields (numeric, geo, range) are stored as BKD trees. When a merge writes a new segment, BKDWriter collects every point of a field, then sorts and partitions them into leaves.
  2. It holds the points in memory up to a budget, maxMBSortInHeap (default 16 MB), and writes them to a temporary file when they do not fit.
  3. Only code changes that budget: a custom codec passes it to the public Lucene90PointsWriter(state, maxPointsInLeafNode, maxMBSortInHeap) constructor.
Diagram of the bug: a custom codec passes a 2200 MB points sort budget; BKDWriter computes that it holds 8,872,566 points of 260 bytes, so 8.4M points stay on heap; HeapPointWriter allocates 260 x 8,400,000 = 2,184,000,000 bytes in int arithmetic, which wraps to -2,110,967,296, so the merge throws NegativeArraySizeException and the IndexWriter closes. With a 2000 MB budget the points spill to disk and the merge works; the fix caps the in-heap count at the largest Java array.
How a 2200 MB budget keeps 8.4M points of 260 bytes on heap, and how 260 x 8,400,000 wraps to a negative array size. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. A custom codec builds its points writer with a bigger sort budget: new Lucene90PointsWriter(state, 512, 2200.0), 2200 MB instead of the default 16 MB. The constructor accepts it.
  2. Merge segments that hold 8.4M points of one field with 16 dimensions of 16 bytes: 260 bytes per point with the doc id, 2,184,000,000 bytes in all, just over 2 GiB.
  3. You expect the merge to sort in heap up to the budget and spill to disk beyond it: the writer "uses up to the specified maxMBSortInHeap heap space".
  4. Instead BKDWriter sees that 8.4M points fit the budget (2200 MB holds 8,872,566) and allocates new byte[260 * 8,400,000] in int arithmetic. The size wraps to -2,110,967,296: NegativeArraySizeException.
  5. IndexWriter treats the failed merge as tragic: it closes and rolls back uncommitted changes, and a new writer with the same budget fails again. The last commit is intact; with 2000 MB the same merge spills to disk and works.

Who is affected

Only applications whose own codec passes a points sort budget of 2048 MB or more (default 16 MB; no test, in-tree path or downstream product found does), once one merge holds over 2 GiB of one multi-dimensional point field that still fits the budget: from 8.26M points of 16x16 bytes or 179M LatLonPoint values. Larger merges spill to disk.

Mechanism, terms and numbers801 words

Point fields (numeric, geo, range) are written as BKD trees. While writing a merged segment, BKDWriter keeps a field's points on heap when they fit the sort budget maxMBSortInHeap and spills them to a temporary file otherwise. The default budget is 16 MB; a custom codec can pass a larger one to the public Lucene90PointsWriter(state, maxPointsInLeafNode, maxMBSortInHeap) constructor, the route a committer pointed to in #8825 (for its predecessor, Lucene60PointsWriter).

BKDWriter turns the budget into a point count, (int) (maxMBSortInHeap * 1024 * 1024 / bytesPerDoc), with no cap at the largest Java array, and HeapPointWriter then allocates new byte[bytesPerDoc * size] as an int multiply. With a budget of 2048 MB or more, a writer that receives more than 2^31-1 bytes of points that still fit the budget overflows: 8.4M points of 260 bytes at 2200 MB give NegativeArraySizeException: -2110967296. Through IndexWriter this is a merge of a multi-dimensional point field, which fails as a tragic event and closes the writer; the last commit survives and a smaller budget recovers. It has been so since 8.1.0, when the old "Buffer too large for Java (2047mb max)" check went away; capping the count at ArrayUtil.MAX_ARRAY_LENGTH / bytesPerDoc fixes it.

The bug, step by step (main 645d4fe14a20)

  1. The budget is public. Lucene90PointsWriter's "Full constructor" and its 3-argument constructor take maxMBSortInHeap with no documented range (Lucene90PointsWriter.java:51-60, :106-110); the 1-argument constructor uses 512 and 16.0 (:112-122). For each field it builds a BKDWriter with that budget and totalPointCount = values.size() (:145-153).
  2. It becomes a point count with no upper cap. maxPointsSortInHeap = (int) ((maxMBSortInHeap * 1024 * 1024) / (config.bytesPerDoc())) (BKDWriter.java:196). The constructor's only checks on the budget are maxMBSortInHeap >= 0 (:212-216) and that one leaf fits (:198-209); verifyParams also rejects a negative totalPointCount (:217-220).
  3. The first add() decides heap or disk. initPointWriter() (:223-233) opens an OfflinePointWriter if totalPointCount > maxPointsSortInHeap, else new HeapPointWriter(config, Math.toIntExact(totalPointCount)) (:231).
  4. The heap buffer size is an int product. this.block = new byte[config.bytesPerDoc() * size] (HeapPointWriter.java:43). Above 2^31-1 bytes it wraps.

The numbers

16 dimensions (8 indexed) x 16 bytes plus a 4-byte doc id = 260 bytes per point. A 2200 MB budget = 2,306,867,200 bytes holds 8,872,566 points, so 8.4M points stay on heap. 8,400,000 x 260 = 2,184,000,000 > 2,147,483,647, and the int product is 2,184,000,000 - 2^32 = -2,110,967,296.

  • The first-step window: more than 2^31-1 bytes and no more than the budget. At 2200 MB and 260 bytes that is 8,259,553 to 8,872,566 points; 8,259,552 points still fit one array (executed by the independent reproducer), and a larger count spills at step 3 (executed by the fact check: 8,872,566 points fail with -1988100136, while 8,872,567 and 20,000,000 points spill to a temporary file on the first add()).
  • Only budgets of 2048 MB or more: at exactly 2048 MB only when bytesPerDoc is a power of two; 2047 MB can never overflow (arithmetic; the hunt's SMT model agrees over all 256 field shapes).
  • At 4096 MB or more the product can also wrap to a small or zero length. The one such case run (4096 MB, 1-D 4-byte points, length 0) failed on the first add() (IllegalStateException with -ea, ArrayIndexOutOfBoundsException with -da); no silent corruption was seen.
  • Same root cause, model only, not executed: BKDRadixSelector sizes its heap writers from the same count (BKDRadixSelector.java:551-581): the delta writer can overflow above 2048 MB even for merges larger than the budget, when the histogram bucket holding the partition point (:291) lands in the window, which depends on the data; the partition writers above 4096 MB. With an absurd maxPointsInLeafNode, switchToHeap (BKDWriter.java:1606-1609; 8,259,553 or more for 260-byte points) and OneDimensionBKDWriter's leaf buffer (:687, maxPointsInLeafNode * packedBytesLength(); 134,217,728 or more for 16-byte 1-D points) overflow the same way.

How a merge gets there

A multi-dimensional field is merged by PointsWriter.mergeOneField, which sums the sources' PointValues.size() (deleted documents included) into totalPointCount and re-adds every point (PointsWriter.java:43-170: the sum at :43-57, size() at :107-110, the re-add at :117-170; writer.add at Lucene90PointsWriter.java:173). 1-D fields use the bulk BKDWriter.merge when every source is a Lucene90PointsReader and the generic path otherwise (Lucene90PointsWriter.java:192-203). Flushes sort a MutablePointTree in place (:155-161) and never call add(). Natural merges take the same path (the availability probe's fresh writers failed on maybeMerge()), and addIndexes(CodecReader...) writes through the same SegmentMerger (IndexWriter.java:3491, :3568-3575; code reading, not run). A merge failure other than an abort (handleMergeException swallows a non-external MergeAbortedException, IndexWriter.java:5043-5053), such as this one, goes to tragicEvent(t, "merge") and is rethrown (:5109-5113).

Exposure and likelihood342 words
  • Who is exposed. Applications with a custom codec that passes a budget of 2048 MB or more to Lucene90PointsWriter(state, maxPointsInLeafNode, maxMBSortInHeap), or that use BKDWriter directly. The default codec uses 16 MB. No in-tree test uses 2048 MB or more (randomized codecs draw 3-8 MB, RandomCodec 5-8 MB; direct BKD tests use 0.001 to 16 MB), and code search of the major downstream search servers found none that raises the budget.
  • Scale. One merge of one point field with more than 2^31-1 bytes of points (points x bytes per point, deleted documents included) that still fit the budget: more than 8,259,552 points of 16 dims x 16 bytes (the widest shape a FieldType allows), about 179M LatLonPoint values (12 bytes each) or about 67M LatLonShape triangles (32 bytes each). At 2200 MB the window for 16x16-byte points is 8,259,553 to 8,872,566 points; larger merges spill to disk at once (executed), though a later radix-selector buffer can still overflow, depending on the data (model only). For users who raised the budget for big point fields, ordinary growth can reach the window.
  • What happens (executed on main a9779ef3c193 with -ea and -da and on 10.5.2; the BKD, points-writer and IndexWriter code is identical at 645d4fe14a20): forceMerge(1) of 84,000 documents x 100 points of a 16x8x16 field throws NegativeArraySizeException: -2110967296; isOpen() is false and getTragicException() is that exception; 1,000 uncommitted documents and an uncommitted delete are rolled back; later calls throw AlreadyClosedException; two fresh writers with the same budget fail again on their next ordinary merge.
  • What does not happen. The last commit stays readable and CheckIndex reports it clean; a 2000 MB budget merges the same segments into one correct segment of 8,400,000 points. No wrong or extra hits, no hang, and no query path is involved; the overflowing allocation fails before reserving its oversized length.
  • Likelihood: rare. It needs both the unusual budget and the large field. It is still worth fixing: the value is accepted, the failure is late, raw and tragic, and the fix is one statement.

Minimal repro

One JUnit test on the public BKDWriter API: a 16-dimension field, a 2200 MB budget, 8.4M points declared, one add(). It fails before any large allocation, so the default test heap is enough. Deterministic and single-threaded; put it in core's test tree and run it with gradle.

TestBKDWriterSortBufferOver2GB.java (one test, public API)37 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.util.bkd;

import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestBKDWriterSortBufferOver2GB extends LuceneTestCase {

  public void testAddWithSortBufferAbove2048MB() throws Exception {
    // 16 dims (8 indexed) x 16 bytes = 260 bytes per point; 8.4M points = 2,184,000,000 bytes
    BKDConfig config = new BKDConfig(16, 8, 16, 512);
    try (Directory dir = new ByteBuffersDirectory();
        BKDWriter w = new BKDWriter(1, dir, "tmp", config, 2200, 8_400_000L)) {
      try {
        w.add(new byte[config.packedBytesLength()], 0);
      } catch (NegativeArraySizeException e) {
        throw new AssertionError("expected add() to keep the point on heap or spill it, got " + e, e);
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestBKDWriterSortBufferOver2GB.java lucene/core/src/test/org/apache/lucene/util/bkd/
./gradlew :lucene:core:test --tests org.apache.lucene.util.bkd.TestBKDWriterSortBufferOver2GB
# assertions off fails the same way: add -Ptests.asserts=false
Expected
add() keeps the point on heap or spills it to disk, and the test passes.
Observed
NegativeArraySizeException: -2110967296 from HeapPointWriter.<init> (8,400,000 x 260 bytes wrapped), with assertions on and off.

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

More files, environment, full output290 words

The test builds the BKDWriter that Lucene90PointsWriter(state, 512, 2200.0) creates for each field it merges: BKDConfig(16, 8, 16, 512) (260 bytes per point), budget 2200, totalPointCount 8,400,000. totalPointCount is documented as an upper bound, so a single add() is legal. It uses only public constructors and methods; the package is the one of the upstream TestBKD. The test's own code draws no random values, and seeds DEADBEEF and 5EED1234 give the same failure.

It needs Lucene 10.0 or later as written (BKDConfig became a record in 10.0.0); the follow-up's version-specific check covers 8.x and 9.x. Before pasting it, run ./gradlew tidy: google-java-format wraps the long throw new AssertionError(...) line, the only change, and the tidied test behaves the same.

As a regression test it is the same test: it fails on stock and passes with the recommended fix, where the writer spills its one point to a temporary file. The IndexWriter consequence (a forceMerge(1) that closes the writer) needs about 2.1 GB of real points and a 3 GB heap, so it is not part of the test; it is described under "Who is affected".

Environment

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

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF (the same with -Ptests.asserts=false)
TestBKDWriterSortBufferOver2GB > testAddWithSortBufferAbove2048MB FAILED
    java.lang.AssertionError: expected add() to keep the point on heap or spill it, got java.lang.NegativeArraySizeException: -2110967296
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:53C6FD9796C8AA09]:0)
        at org.apache.lucene.util.bkd.TestBKDWriterSortBufferOver2GB.testAddWithSortBufferAbove2048MB(TestBKDWriterSortBufferOver2GB.java:33)

        Caused by:
        java.lang.NegativeArraySizeException: -2110967296
            at org.apache.lucene.util.bkd.HeapPointWriter.<init>(HeapPointWriter.java:43)
            at org.apache.lucene.util.bkd.BKDWriter.initPointWriter(BKDWriter.java:231)
            at org.apache.lucene.util.bkd.BKDWriter.add(BKDWriter.java:253)
            at org.apache.lucene.util.bkd.TestBKDWriterSortBufferOver2GB.testAddWithSortBufferAbove2048MB(TestBKDWriterSortBufferOver2GB.java:31)

# the standalone runner gives the same message on main (-ea, -da) and on the 10.0.0 (-ea) and 10.6.0 (-ea, -da) jars
# with the recommended fix: the test passes (assertions on and off)

Why it looks unintended

  • The javadoc describes a budget with a disk spill: the writer "uses up to the specified maxMBSortInHeap heap space" and spills beyond it.
  • The constructor accepts 2200 MB, while Lucene rejects the same 2 GB limit elsewhere, as it did here until 8.0.0.
  • A committer pointed users to the points-writer constructor (then Lucene60PointsWriter) for bigger budgets (#8825).
The full argument320 words
  • The javadoc. Class javadoc: it "uses up to the specified {@code maxMBSortInHeap} heap space for writing" (BKDWriter.java:74-76); DEFAULT_MAX_MB_SORT_IN_HEAP is the "Default maximum heap to use, before spilling to (slower) disk" (:98-99); totalPointCount is "An upper bound on how many points the caller will add (includes deletions)" (:135). That creates a reasonable expectation of a spill rather than a crash; no javadoc or test states an upper bound.
  • Validation. The constructor rejects negative budgets and, with a clear IllegalArgumentException, budgets too small for one leaf (:198-216), but has no upper check. The public 3-argument Lucene90PointsWriter constructor that takes the budget belongs to a class with no @lucene.experimental tag (Lucene90PointsWriter.java:40-41; nor has BKDConfig).
  • The same limit is guarded elsewhere. OfflineSorter.BufferSize ("Buffer too large for Java (2047MB max)", OfflineSorter.java:84-91); IndexWriterConfig.setRAMPerThreadHardLimitMB rejects >= 2048 MB (IndexWriterConfig.java:382-386); inside BKDWriter, MergeIntersectsVisitor.grow checks MAX_ARRAY_LENGTH (:370-377), checkMaxLeafNodeCount too (:904-911), and initPointWriter uses Math.toIntExact (:231). Only the multiply in HeapPointWriter was missed.
  • The suggested route. In #8825 (LUCENE-7774, 2017) mikemccand answered a request for configurable BKD buffers: "The Lucene60PointsWriter ctor already accepts maxMBSortInHeap, which it passes onto BKDWriter, so users could make a custom codec changing this setting themselves?"; the reporter (romseygeek) accepted that and closed the issue. (In 2017 budgets of 2048 MB or more were still rejected up front.)
  • History. From 6.1.0 to 8.0.0 the constructor built an OfflineSorter.BufferSize from the budget and rejected 2048 MB or more (6.0.0 did so only when sorting offline). LUCENE-8673 removed that call and LUCENE-8699 introduced the single int-sized array, in the same release (8.1.0); no commit, issue or PR review of those changes mentions the limit. A lost guard, not a design decision.
  • Precedent. Int overflows at other BKD sites were fixed as bugs: #11713 (LUCENE-10678, the merge partition point) and #11907 (splitPackedValues).
The devil's-advocate check187 words

The strongest case for "intended or unsupported" (the intended-behaviour devil's advocate and the cross-model reviews): the BKD classes are @lucene.experimental (BKDWriter, Lucene90PointsFormat, PointsWriter) or @lucene.internal (HeapPointWriter, BKDRadixSelector); a 2 GB+ sort budget is untested; committers declined new codec knobs (#10966, LUCENE-9927, Won't Fix), and in #8825 rmuir said it "would be better to speed up e.g. offlinesorter ... so that large amounts of ram are not necessary"; and one Java array cannot hold 2 GB anyway.

Why it fails: Lucene90PointsWriter, whose public constructor takes the budget, carries no such tag; experimental means the API may change, not that a value it accepts may crash a merge; #10966 declined adding knobs and did not withdraw the existing public parameter; "cannot be one array" is exactly why the writer must cap or reject the value, as OfflineSorter did for this same parameter until 8.0.0; and the failure is a raw NegativeArraySizeException deep inside a merge that closes the writer, not a documented error. Verdict: not intended, a defect (the independent reproduction, the cross-model review, the intended-behaviour check and the adjudication agree).

Suggested fix

Recommended Cap maxPointsSortInHeap at ArrayUtil.MAX_ARRAY_LENGTH / bytesPerDoc

Cap the in-heap point count at the largest Java array in the BKDWriter constructor, so larger inputs spill to disk. One statement; with it 193 BKD and points tests including the repro run with 0 failures (11 skipped), and the core static checks pass.

-    // Maximum number of points we hold in memory at any time
-    maxPointsSortInHeap = (int) ((maxMBSortInHeap * 1024 * 1024) / (config.bytesPerDoc()));
+    // Maximum number of points we hold in memory at any time; the on-heap buffers are single byte[]
+    // of bytesPerDoc * points, so never more than the largest Java array:
+    maxPointsSortInHeap =
+        (int)
+            Math.min(
+                (maxMBSortInHeap * 1024 * 1024) / (config.bytesPerDoc()),
+                ArrayUtil.MAX_ARRAY_LENGTH / config.bytesPerDoc());
All options and trade-offs3 options · 504 words

One fix was run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF); it is the hunt's candidate fix, unchanged. Two alternatives are described, not run.

Cap maxPointsSortInHeap at ArrayUtil.MAX_ARRAY_LENGTH / bytesPerDoc (validated by the checks) (recommended)

In the BKDWriter constructor (BKDWriter.java:195-196). BKDRadixSelector receives the same value (BKDWriter.java:975), so its heap writers are covered too.

-    // Maximum number of points we hold in memory at any time
-    maxPointsSortInHeap = (int) ((maxMBSortInHeap * 1024 * 1024) / (config.bytesPerDoc()));
+    // Maximum number of points we hold in memory at any time; the on-heap buffers are single byte[]
+    // of bytesPerDoc * points, so never more than the largest Java array:
+    maxPointsSortInHeap =
+        (int)
+            Math.min(
+                (maxMBSortInHeap * 1024 * 1024) / (config.bytesPerDoc()),
+                ArrayUtil.MAX_ARRAY_LENGTH / config.bytesPerDoc());

Trade-offs

  • No hot-path cost: it runs once per writer.
  • Budgets of 2048 MB or more stay accepted, but no single in-heap buffer exceeds about 2 GiB; larger inputs spill to disk, which the javadoc's "up to" allows.
  • Behaviour change: the existing "at least one leaf fits" check now also rejects a maxPointsInLeafNode above MAX_ARRAY_LENGTH / bytesPerDoc (8,259,553 or more for 260-byte points), whose leaf buffers could not be allocated anyway (a static argument; not executed).
  • A smaller one: ArrayUtil.MAX_ARRAY_LENGTH is Integer.MAX_VALUE minus the array header, 2,147,483,631 (ArrayUtil.java:32-34), so a fractional budget above 2,147,483,631 bytes (just under 2048 MB) can also be capped a few points lower than today; a total that stayed on heap then spills (arithmetic; for 260-byte points both limits give 8,259,552).

What the checks validated

  • Stock: the repro fails with assertions on and off.
  • Fix: the repro passes with assertions on and off.
  • Closest suites (org.apache.lucene.util.bkd.*, TestLucene90PointsFormat, TestLucene90PointsFormatV0, TestPointValues, TestPointQueries, and the repro): 13 classes, 193 tests, 0 failures, 11 skipped (seed DEADBEEF; the fact check repeated it with seed 5EED0613).
  • :lucene:core:check -x test passes with the fix and the tidied repro in the tree (the fix needed no reformatting). The full core suite was not run.
  • Earlier, the same diff shadowed onto the stock jars made the finder's 4 tests pass, including the end-to-end forceMerge(1) of 8.4M points (which then spills to disk), and the independent reproducer's 6 tests; 57 upstream BKD and points-format tests passed with it (one seed).

Reject budgets whose buffer cannot be one array (not run)

In verifyParams, throw IllegalArgumentException when maxMBSortInHeap * 1024 * 1024 exceeds ArrayUtil.MAX_ARRAY_LENGTH, as OfflineSorter.BufferSize and the BKDWriter of 6.1.0-8.0.0 did.

Trade-offs

Loud and explicit, and it restores the pre-8.1.0 behaviour, but it rejects configurations that avoid this overflow today (merges under 2 GiB per field never reach the overflowing multiply; code reading, not run), and the regression test would have to expect the exception from the constructor instead.

Math.multiplyExact in HeapPointWriter (not enough alone)

Compute the buffer size with Math.multiplyExact so the failure is an ArithmeticException with a clear cause.

Trade-offs

The merge still fails and closes the writer; useful only as defence in depth together with the cap.

Downstream note

An application that lets operators tune the points sort budget should keep it below 2048 MB until a fix ships; none of the downstream search servers checked exposes it.

How it was found

Code reading: while an AI tool wrote a translation-validation harness for the BKD write path in non-default configurations, it noticed that HeapPointWriter sizes its buffer with an int multiply that nothing caps. A public-API test on the stock jar confirmed it, and an SMT (Z3) model then mapped which budgets and point counts overflow.

The method in full151 words

Method: non-formal (code reading, then an executed test), a hunt unit on the BKD write path in non-default configurations.

  • While writing the unit's bounded-exhaustive translation-validation harness, which runs at budgets of 16 MB or less and so cannot witness this, the AI tool read HeapPointWriter's allocation and saw that BKDWriter never caps the count it passes.
  • A JUnit on the stock main jar confirmed it: a direct BKDWriter.add at 2200 MB and at 4000 MB, and an end-to-end forceMerge(1) through a custom codec; a 2000 MB control spilled to disk and passed.
  • An SMT (Z3) model written afterwards (every int-sized heap buffer of the BKD write path as a linear obligation, over all 256 field shapes; 19 of 19 queries as expected) gave the exact region: budgets of 2048 MB or more, the minimal point count per shape, and the sibling sites in BKDRadixSelector, switchToHeap and OneDimensionBKDWriter.

Prior reports, history and checks

Prior reports

No prior report: 105 search-tool queries over GitHub issues, PRs and commits, the Lucene mailing lists, the JIRA archive, downstream projects and the web, plus 32 manual searches, on 2026-10-26. No open PR touches this sizing code.

Searches6 searches

Closest items, context only: #8825 (LUCENE-7774), where a committer pointed to the points-writer constructor (then Lucene60PointsWriter) for bigger budgets; #10966 (LUCENE-9927, Won't Fix for a new knob); #9745 (LUCENE-8699, the introducing change, which does not discuss the limit); #11713 (LUCENE-10678) and #11907, int overflows fixed at other BKD sites. Gaps: GitHub search hit its rate limit three times (two queries were retried successfully, a broader query covered the third); web search was throttled and retried.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2640 tool queries (HeapPointWriter, maxMBSortInHeap, maxPointsSortInHeap, NegativeArraySizeException with BKD terms, title searches) plus 6 manual gh search queries141 distinct hits, none about this bug
Open PRs2026-10-26gh pr list --state open for HeapPointWriter, BKDWriter, BKDRadixSelector, maxMBSortInHeap, Lucene90PointsWriter, PointsWriter, bkd; diffs of #12658, #666, #12528, #486, #12610 readnone changes maxPointsSortInHeap, initPointWriter or HeapPointWriter sizing
Commits and history2026-10-263 tool queries; git log -S maxPointsSortInHeap and the history of HeapPointWriter.java in a full-history clone; CHANGES.txt and MIGRATE.mdthe in-heap count was never capped; nothing fixed
JIRA archive2026-10-2610 tool queries, 5 manual30 distinct tickets, none about this bug
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2640 tool queries; manual searches for maxMBSortInHeap, HeapPointWriter, NegativeArraySizeExceptiontest-failure dumps that print a random budget, review traffic of the 2018-2019 BKD rewrites, an unrelated JVM-specific report
Downstream projects and web2026-10-26issue and code search in the major downstream search servers; 14 web queriesno report; the one downstream points writer found that builds BKDWriter itself passes the 16 MB default

History

Introduced by LUCENE-8699 (2019-02-20, one int-sized array in HeapPointWriter) and made reachable in the same release by LUCENE-8673, which dropped the old 2047 MB check, and LUCENE-8703. First affected 8.1.0; every release since fails. 6.1.0 to 8.0.0 reject the budget up front with a clear error; 6.0.0 accepts it and rejects it only when sorting offline. Not fixed upstream.

Versions tested8 rows
  • Introduced by LUCENE-8699 "Use fixed byte array in HeapPointWriter" (#9745, PR apache/lucene-solr#578, by iverase): master 3ef6e015dd12 and the same-day branch_8x backport 66d484086023, 2019-02-20. It replaced the list of 4 KB blocks with one new byte[packedBytesLength * size].
  • Made reachable in the same release: LUCENE-8673 (radix partitioning, 5d1d6448b91, 2019-02-07) removed the constructor's OfflineSorter.BufferSize.megabytes(maxMBSortInHeap) call, which since 6.1.0 (LUCENE-7142, 28a4335813d) had rejected budgets of 2048 MB or more; LUCENE-8703 (15dd4a17386d, 2019-03-01) made initPointWriter() allocate a heap writer of exactly totalPointCount points when that fits the budget.
  • Later: LUCENE-8888 (8.2.0) moved the doc id into the block, so the threshold shifted by 4 bytes per point; LUCENE-9292 (8.7.0) and #13668 (10.0.0, BKDConfig as a record) kept the same product.
  • Executed: one version-specific BKDWriter check (8 dims x 16 bytes, 16.8M points, 2200 MB; control 2000 MB) per API era, 83 runs in all; the minimal test on 10.0.0, 10.6.0 and main.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails, assertions on and off (gradle and the standalone runner)
10.6.0 affectedthe minimal test fails, assertions on and off
10.0.0-10.5.2 (12 releases) affectedthe version check fails (-2077367296); 10.0.0 also fails the minimal test; on 10.5.2 the end-to-end forceMerge(1) closes the writer
9.0.0-9.12.3 (21 releases) affectedthe version check fails (-2077367296); the 2000 MB control passes on 9.12.3
8.2.0-8.11.4 (24 releases) affectedthe version check fails (-2077367296, after LUCENE-8888)
8.1.0, 8.1.1 first affectedthe version check fails (-2144567296); on 8.1.0 forceMerge(1) of 16.8M points closes the writer
8.0.0 last unaffectedthe budget is rejected: IllegalArgumentException "Buffer too large for Java (2047mb max)" (through IndexWriter at the first flush)
6.0.0, 6.1.0, 6.6.6, 7.7.3 fails loudlythe budget is rejected with the same IllegalArgumentException: up front from 6.1.0 on; 6.0.0 accepts it and rejects it only when finish() sorts offline

Independent checks

Confirmed with high confidence by the adjudication after an independent reproducer, an intended-behaviour devil's advocate and read-only reviews by another model family (OpenAI GPT-5.6); a fourth audit re-run and this doc's gradle runs agree.

Checks and verdicts440 words

Every executed check ran on main a9779ef3c193 or 645d4fe14a20 (identical BKD code) and on release jars; the cross-model reviews could only read code, so their executed evidence is a separate re-run.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateOwn JUnit: 3 of 4 fail on main with -ea and -da, on 10.5.2 and 9.12.3 (direct add at 2200 MB and 4000 MB, end-to-end forceMerge(1)); the 2000 MB control passes; a BKDWriter-level program fails on 8.11.4; 4 of 4 pass with the cap.
refute review (OpenAI GPT-5.6, read-only) not real, low confidence (procedural)Could not run Java; agreed with the root cause, found the inputs legal and the intended argument weak; a separate run re-ran the JUnit (3 of 4 fail).
intended-lens review (OpenAI GPT-5.6, read-only) real, mediumNo documented upper bound; OfflineSorter's guard and BKDWriter's own array-limit checks are precedents.
Root-cause review (OpenAI GPT-5.6, read-only) and its executed supplement real, mediumRoot cause agrees; the supplement re-ran the JUnit on main (3 of 4 fail), passed 4 of 4 with the fix, and ran 57 upstream BKD and points tests with the fix (0 failures).
Independent reproducer realOwn 6 tests written from the claim: 3 fail on main, 10.5.2 and 9.12.3; sharp boundary (8,259,552 points pass, 8,259,553 fail); zero-length wrap at 4096 MB; a 9-segment merge closes the writer; 6 of 6 pass with the fix.
Intended-behaviour devil's advocate realNo intended, documented, misuse or unreachable argument survives; re-ran the JUnit on main and 10.5.2.
adjudication confirmed, highRe-ran the independent reproduction's tests on main with -ea and -da (3 of 6 fail each); class crash, severity low.
Follow-up new; first affected 8.1.0History (83 runs, 6.0.0 to main), prior-art search (verdict new) and an availability probe (tragic close, rollback, recurrence, clean last commit, recovery at 2000 MB).
this doc (2026-10-31) real, fix validatedThe minimal test through gradle on 645d4fe14a20 (fails, assertions on and off) and on the 10.0.0 and 10.6.0 jars (fails); the cap through gradle: 193 closest tests including the repro (0 failures, 11 skipped) and :lucene:core:check pass.
fact checks of this doc (2026-10-31) real; wording correctedAn independent fact check re-ran the minimal test (main with -ea and -da, the 10.0.0 and 10.6.0 jars, the verbatim gradle command) and the window's upper bound (8,872,566 points fail; 8,872,567 and 20,000,000 spill); a read-only review by another model family (OpenAI GPT-5.6) re-checked the code and the arithmetic. Their corrections are applied here: the window's upper bound, the 6.0.0 behaviour, merge aborts, the 1-D leaf threshold.

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

Finding record

LFV-613, snapshot of 2026-11-06

Registry title
BKDWriter does not cap its in-heap point buffer at the Java array limit, so with maxMBSortInHeap >= 2048 MB a points merge of more than 2^31 bytes throws NegativeArraySizeException and closes the IndexWriter
Area
util / BKD points (BKDWriter in-heap sort buffer sizing: HeapPointWriter and the BKDRadixSelector heap writers, reached through Lucene90PointsWriter's public maxMBSortInHeap constructor)
Priority
Low, rank 963: A custom points sort budget of at least 2048 MB plus a points merge over 2 GiB closes the IndexWriter with NegativeArraySizeException, an extreme-scale opt-in case that is easier to reach than the giant-token case LFV-385.
Impact class
crash
Impact note
Only applications whose own codec builds Lucene90PointsWriter(state, maxPointsInLeafNode, maxMBSortInHeap) (or BKDWriter directly) with a points sort budget of 2048 MB or more are exposed (the default is 16 MB; no in-tree production path, upstream test or downstream product found uses such a budget), and only once more than 2^31-1 bytes of one point field (points x bytesPerDoc, deleted documents included, for example about 179M LatLonPoint values or 8.26M points of 16 dims x 16 bytes) enter one BKDWriter, in practice a merge of a multi-dimensional field; the merge throws NegativeArraySizeException, IndexWriter closes itself as a tragic event and rolls back uncommitted changes, every new writer with the same budget fails again when that merge is selected, while the last commit stays intact (CheckIndex clean) and lowering the budget below 2048 MB recovers.
Summary
You give a points format a sort budget of 2048 MB or more, for example new Lucene90PointsWriter(state, 512, 2200.0) in a custom codec, and merge segments that hold more than 2 GiB of points of one multi-dimensional field (in the repro 8.4M points of 16 dims x 16 bytes). You expect the merge to succeed, because BKDWriter 'uses up to the specified maxMBSortInHeap heap space for writing', spills to disk beyond it, and its constructor accepts the value. Instead BKDWriter sizes its in-heap buffer as budget / bytesPerDoc points with no cap at the Java array limit, HeapPointWriter computes bytesPerDoc * size in int, the length wraps negative, and forceMerge(1) throws NegativeArraySizeException (-2110967296) and leaves the IndexWriter closed with that tragic exception.
Introduced
3ef6e015dd127b356e4163eb48411be94fb2d50c (master) and its same-day branch_8x backport 66d4840860230613881aa42cf5837478d156f6e6 (the copy that shipped), LUCENE-8699 'Use fixed byte array in HeapPointWriter' (apache/lucene#9745, PR apache/lucene-solr#578, by GitHub iverase): HeapPointWriter's list of 4 KB blocks became one byte[packedBytesLength * size] computed in int, today's new byte[config.bytesPerDoc() * size]. Two commits of the same release made it reachable: LUCENE-8673 (5d1d6448b91 / branch_8x a843b3aa35f, radix partitioning, 2019-02-07) removed BKDWriter's OfflineSorter.BufferSize.megabytes(maxMBSortInHeap) call, which since 6.1.0 (LUCENE-7142) had rejected budgets >= 2048 MB with 'Buffer too large for Java (2047mb max)', and LUCENE-8703 (15dd4a17386d / branch_8x 748d483e67b, 2019-03-01) made initPointWriter() allocate a heap writer of exactly totalPointCount points whenever that count fits the budget [read: git log -S/-G, git blame and git tag --contains in the history clone; exec: 8.0.0 rejects the budget with IllegalArgumentException, 8.1.0 fails with NegativeArraySizeException] (2019-02-20 (LUCENE-8699, master and branch_8x; the enabling LUCENE-8673 is from 2019-02-07 and LUCENE-8703 from 2019-03-01))
First affected version
8.1.0 (tagged 2019-05-13) [exec, -ea and -da: the BKDWriter-level check fails with NegativeArraySizeException -2144567296, and through IndexWriter forceMerge(1) of 16.8M points of 128 bytes with Lucene60PointsWriter(state, 512, 2200.0) closes the writer with that tragic exception]; 8.0.0 is the last unaffected release: 6.0.0, 6.1.0, 6.6.6, 7.7.3 and 8.0.0 [exec] reject the same budget with IllegalArgumentException 'Buffer too large for Java (2047mb max)' (in 8.0.0 through IndexWriter at the first flush), so the setting never worked before 8.1.0, but it failed at once with a clear message
Affected versions
every release from 8.1.0 through 10.6.0 and main a9779ef3c193 and 645d4fe14a20 [exec 2026-10-31: the minimal test fails on the 10.6.0 release jars (SHA-1 checked) and on main 645d4fe14a20 with -ea and -da, NegativeArraySizeException -2110967296; exec: official release jars, SHA-1 checked, JDK 25, seed DEADBEEF: the same BKDWriter-level check (8 dims x 16 bytes, 16.8M points, 2200 MB) fails on all 59 releases 8.1.0-10.5.2 and on main, -2144567296 on 8.1.x and -2077367296 from 8.2.0 on (LUCENE-8888 moved the docID into the block), while its 2000 MB control passes; the finder's JUnit fails 3 of 4 on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 3 of 6 on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3; the finder's BKDWriter-level main() fails on 8.11.4]. Not affected: 6.0.0-8.0.0 (the budget is rejected) [exec on 6.0.0, 6.1.0, 6.6.6, 7.7.3, 8.0.0]. The BKD files are byte-identical in main ea57360912ed and a9779ef3c193 [read].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading; also reached by Z3
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new, read-only: 105 scripted prior-report search queries over apache/lucene issues and PRs (40), commit messages (3), downstream repositories (2), the dev@, java-user@, issues@ and commits@ lists (40), the migrated JIRA (10) and the web (10), 1,783 distinct hits with 0 high overlap, plus 32 manual GitHub, JIRA, list, web and git-history searches; the finder's, the intended-behaviour check's and the adjudication's searches agree, while the independent reproduction's own search hit the rate limit): no issue, PR, review comment, commit, JIRA entry, list thread, downstream report or web page describes an int overflow of HeapPointWriter's buffer, a missing array-limit cap on maxPointsSortInHeap or a NegativeArraySizeException from initPointWriter or the BKDRadixSelector heap writers. Nearest items, context only: #8825 (LUCENE-7774, a committer pointed users to the maxMBSortInHeap constructor through a custom codec), #10966 (LUCENE-9927, Won't Fix for adding more knobs), #9745 (LUCENE-8699, the introducing change, no mention of the limit), and two fixed int overflows at other BKD sites (#11713 / LUCENE-10678 partition point; #11907 splitPackedValues). Not fixed: no open PR changes maxPointsSortInHeap, initPointWriter or HeapPointWriter sizing (#12658, #666, #12528, #486 and #12610 read). Limits: GitHub search hit its rate limit three times (two queries were retried successfully; a plain 'HeapPointWriter' query covers the third); web search was throttled twice and retried; JIRA text search matches whole words.
Lucene commit tested
main 645d4fe14a20 (gradle with assertions on and off, and the standalone runner); the minimal test on the 10.0.0 and 10.6.0 release jars; the hunt's tests, including the end-to-end forceMerge(1) and the availability probe, on main a9779ef3c193 (identical BKD, points-writer and IndexWriter code), 10.5.2 and 9.12.3; the follow-up's version-specific BKDWriter check on 6.0.0, 6.1.0, 6.6.6, 7.7.3 and 8.0.0 (budget rejected; in 6.0.0 only when sorting offline) and on all 59 releases from 8.1.0 to 10.5.2 (fail); the recommended fix through gradle in a scratch worktree of 645d4fe14a20. 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/util/bkd/BKDWriter.java:74-76 (at a9779ef3c193) (class javadoc: 'uses up to the specified maxMBSortInHeap heap space for writing'), :98-99 (DEFAULT_MAX_MB_SORT_IN_HEAP = 16.0f, 'Default maximum heap to use, before spilling to (slower) disk'), :195-209 (maxPointsSortInHeap = (int) ((maxMBSortInHeap * 1024 * 1024) / bytesPerDoc), only a lower-bound check), :211-220 (verifyParams: >= 0 only), :223-233 (initPointWriter: HeapPointWriter of totalPointCount points when totalPointCount <= maxPointsSortInHeap)
  • lucene/core/src/java/org/apache/lucene/util/bkd/HeapPointWriter.java:41-52 (at a9779ef3c193) (block = new byte[config.bytesPerDoc() * size], an int multiply)
  • lucene/core/src/java/org/apache/lucene/util/bkd/BKDRadixSelector.java:552-581 (at a9779ef3c193) (getDeltaPointWriter and getPointWriter size heap writers from the same maxPointsSortInHeap)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90PointsWriter.java:52-60 (at a9779ef3c193) ('Full constructor', public, takes maxMBSortInHeap), :106-110 (public 3-argument constructor), :116-122 (defaults 512 and 16.0), :163-177 (BKDWriter.add in the merge path)
  • lucene/core/src/java/org/apache/lucene/codecs/PointsWriter.java:43-77 (at a9779ef3c193) (mergeOneField sums the sources' PointValues.size(), deleted documents included, and re-adds every point)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5109-5113 (at a9779ef3c193) (a merge failure calls tragicEvent and rethrows)
  • lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:84-91 (at a9779ef3c193) ('Buffer too large for Java (2047MB max)') and lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:382-386 (setRAMPerThreadHardLimitMB < 2048): the same limit guarded elsewhere
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/bkd/BKDWriter.java:74-76, 98-99, 135, 196, 198-221, 223-233, 252-253, 687, 975, 1606-1609, 1928-1935
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/bkd/HeapPointWriter.java:41-52
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/bkd/BKDRadixSelector.java:291, 551-582
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90PointsWriter.java:51-60, 106-131, 145-180, 192-203
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/PointsWriter.java:43-57, 107-110, 117-170
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3491, 3568-3575, 5043-5053, 5109-5113
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90PointsFormat.java:88-89; a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90PointsWriter.java:112-131; a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/bkd/BKDWriter.java:98-99
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomCodec.java:232; a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/util/bkd/TestBKD.java:57, 99, 359; Test4BBKDPoints.java:52; lucene/core/src/java/org/apache/lucene/index/PointValues.java:107
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/bkd/BKDWriter.java:74-76, 81, 98-99, 135, 198-216, 231, 370-377, 904-911
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:84-91; a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:382-386
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90PointsWriter.java:40-41 (no tag); codecs/lucene90/Lucene90PointsFormat.java:46; codecs/PointsWriter.java:29; util/bkd/HeapPointWriter.java:27; util/bkd/BKDRadixSelector.java:33; util/bkd/BKDConfig.java:23-31 (no tag)
  • github.com/apache/lucene/issues/8825 (LUCENE-7774; mikemccand and romseygeek comments, read 2026-10-31)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:84-91; a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/bkd/BKDWriter.java:975
History
  • a Lucene clone: git log 645d4fe14a20..2f424fd428da (no change to the BKD package, Lucene90PointsWriter.java or PointsWriter.java)

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

Repro files

About the diagrams

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.