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.
- Point fields (numeric, geo, range) are stored as BKD trees. When a merge writes a new segment,
BKDWritercollects every point of a field, then sorts and partitions them into leaves. - 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. - Only code changes that budget: a custom codec passes it to the public
Lucene90PointsWriter(state, maxPointsInLeafNode, maxMBSortInHeap)constructor.
- 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. - 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.
- 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".
- Instead
BKDWritersees that 8.4M points fit the budget (2200 MB holds 8,872,566) and allocatesnew byte[260 * 8,400,000]inintarithmetic. The size wraps to -2,110,967,296:NegativeArraySizeException. - 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.
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 budget is public.
Lucene90PointsWriter's "Full constructor" and its 3-argument constructor takemaxMBSortInHeapwith 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 aBKDWriterwith that budget andtotalPointCount = values.size()(:145-153). - 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 aremaxMBSortInHeap >= 0(:212-216) and that one leaf fits (:198-209);verifyParamsalso rejects a negativetotalPointCount(:217-220). - The first
add()decides heap or disk.initPointWriter()(:223-233) opens anOfflinePointWriteriftotalPointCount > maxPointsSortInHeap, elsenew HeapPointWriter(config, Math.toIntExact(totalPointCount))(:231). - The heap buffer size is an
intproduct.this.block = new byte[config.bytesPerDoc() * size](HeapPointWriter.java:43). Above 2^31-1 bytes it wraps.
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 firstadd()). - Only budgets of 2048 MB or more: at exactly 2048 MB only when
bytesPerDocis 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()(IllegalStateExceptionwith-ea,ArrayIndexOutOfBoundsExceptionwith-da); no silent corruption was seen. - Same root cause, model only, not executed:
BKDRadixSelectorsizes 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 absurdmaxPointsInLeafNode,switchToHeap(BKDWriter.java:1606-1609; 8,259,553 or more for 260-byte points) andOneDimensionBKDWriter's leaf buffer (:687,maxPointsInLeafNode * packedBytesLength(); 134,217,728 or more for 16-byte 1-D points) overflow the same way.
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 useBKDWriterdirectly. The default codec uses 16 MB. No in-tree test uses 2048 MB or more (randomized codecs draw 3-8 MB,RandomCodec5-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
FieldTypeallows), about 179MLatLonPointvalues (12 bytes each) or about 67MLatLonShapetriangles (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
a9779ef3c193with-eaand-daand on 10.5.2; the BKD, points-writer and IndexWriter code is identical at645d4fe14a20):forceMerge(1)of 84,000 documents x 100 points of a 16x8x16 field throwsNegativeArraySizeException: -2110967296;isOpen()is false andgetTragicException()is that exception; 1,000 uncommitted documents and an uncommitted delete are rolled back; later calls throwAlreadyClosedException; 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.
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.
/*
* 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);
}
}
}
}
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: -2110967296fromHeapPointWriter.<init>(8,400,000 x 260 bytes wrapped), with assertions on and off.
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".
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on the 10.0.0 and 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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)- 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_HEAPis the "Default maximum heap to use, before spilling to (slower) disk" (:98-99);totalPointCountis "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-argumentLucene90PointsWriterconstructor that takes the budget belongs to a class with no@lucene.experimentaltag (Lucene90PointsWriter.java:40-41; nor hasBKDConfig). - The same limit is guarded elsewhere.
OfflineSorter.BufferSize("Buffer too large for Java (2047MB max)",OfflineSorter.java:84-91);IndexWriterConfig.setRAMPerThreadHardLimitMBrejects >= 2048 MB (IndexWriterConfig.java:382-386); insideBKDWriter,MergeIntersectsVisitor.growchecksMAX_ARRAY_LENGTH(:370-377),checkMaxLeafNodeCounttoo (:904-911), andinitPointWriterusesMath.toIntExact(:231). Only the multiply inHeapPointWriterwas missed. - The suggested route. In #8825 (LUCENE-7774, 2017) mikemccand answered a request for configurable BKD buffers: "The
Lucene60PointsWriterctor already acceptsmaxMBSortInHeap, which it passes ontoBKDWriter, 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.BufferSizefrom 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 singleint-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).
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
maxPointsInLeafNodeaboveMAX_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_LENGTHisInteger.MAX_VALUEminus 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 testpasses 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).
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.
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.
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.
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 thatBKDWriternever caps the count it passes. - A JUnit on the stock main jar confirmed it: a direct
BKDWriter.addat 2200 MB and at 4000 MB, and an end-to-endforceMerge(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 inBKDRadixSelector,switchToHeapandOneDimensionBKDWriter.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-26 | 40 tool queries (HeapPointWriter, maxMBSortInHeap, maxPointsSortInHeap, NegativeArraySizeException with BKD terms, title searches) plus 6 manual gh search queries | 141 distinct hits, none about this bug |
| Open PRs | 2026-10-26 | gh pr list --state open for HeapPointWriter, BKDWriter, BKDRadixSelector, maxMBSortInHeap, Lucene90PointsWriter, PointsWriter, bkd; diffs of #12658, #666, #12528, #486, #12610 read | none changes maxPointsSortInHeap, initPointWriter or HeapPointWriter sizing |
| Commits and history | 2026-10-26 | 3 tool queries; git log -S maxPointsSortInHeap and the history of HeapPointWriter.java in a full-history clone; CHANGES.txt and MIGRATE.md | the in-heap count was never capped; nothing fixed |
| JIRA archive | 2026-10-26 | 10 tool queries, 5 manual | 30 distinct tickets, none about this bug |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | 40 tool queries; manual searches for maxMBSortInHeap, HeapPointWriter, NegativeArraySizeException | test-failure dumps that print a random budget, review traffic of the 2018-2019 BKD rewrites, an unrelated JVM-specific report |
| Downstream projects and web | 2026-10-26 | issue and code search in the major downstream search servers; 14 web queries | no report; the one downstream points writer found that builds BKDWriter itself passes the 16 MB default |
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
3ef6e015dd12and the same-day branch_8x backport66d484086023, 2019-02-20. It replaced the list of 4 KB blocks with onenew byte[packedBytesLength * size]. - Made reachable in the same release: LUCENE-8673 (radix partitioning,
5d1d6448b91, 2019-02-07) removed the constructor'sOfflineSorter.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) madeinitPointWriter()allocate a heap writer of exactlytotalPointCountpoints 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,
BKDConfigas a record) kept the same product. - Executed: one version-specific
BKDWritercheck (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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails, assertions on and off (gradle and the standalone runner) |
| 10.6.0 | affected | the minimal test fails, assertions on and off |
| 10.0.0-10.5.2 (12 releases) | affected | the 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) | affected | the version check fails (-2077367296); the 2000 MB control passes on 9.12.3 |
| 8.2.0-8.11.4 (24 releases) | affected | the version check fails (-2077367296, after LUCENE-8888) |
| 8.1.0, 8.1.1 | first affected | the version check fails (-2144567296); on 8.1.0 forceMerge(1) of 16.8M points closes the writer |
| 8.0.0 | last unaffected | the 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 loudly | the 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 |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Own 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, medium | No 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, medium | Root 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 | real | Own 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 | real | No intended, documented, misuse or unreachable argument survives; re-ran the JUnit on main and 10.5.2. |
| adjudication | confirmed, high | Re-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.0 | History (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 validated | The 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 corrected | An 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. |
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 468 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,535 words
- 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-endforceMerge(1)and the availability probe, on maina9779ef3c193(identical BKD, points-writer and IndexWriter code), 10.5.2 and 9.12.3; the follow-up's version-specificBKDWritercheck 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 of645d4fe14a20. 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/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-1935a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/bkd/HeapPointWriter.java:41-52a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/bkd/BKDRadixSelector.java:291, 551-582a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90PointsWriter.java:51-60, 106-131, 145-180, 192-203a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/PointsWriter.java:43-57, 107-110, 117-170a 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-99a 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-911a 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-386a 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)
- TestBKDWriterSortBufferOver2GB.java (one test, public API):
repro/LFV-613/src/org/apache/lucene/util/bkd/TestBKDWriterSortBufferOver2GB.java(37 lines)
assets/LFV-613/bug.png(468 KB)
