All findings · Lucene finding · LFV-172 · stored-string-over-1gib-utf8-unreadable

A stored string of about 1 GiB of non-Latin-1 text is accepted by IndexWriter but cannot be read back

IndexWriter accepts and commits a stored field of 357,913,941 CJK characters (2^30 - 1 UTF-8 bytes, about half its limit), but every load of it then throws OutOfMemoryError, even with 10 GB of heap; a visitor merge, and an index-sorted flush of its segment, fail and close IndexWriter.

Summary

Priority
Low, rank 962A stored string of at least 2^30-1 UTF-8 bytes of non-Latin-1 text is accepted but can never be read back (OutOfMemoryError, and a tragic flush under index sorting), reachable with one ~1 GiB field.
Impact
crash
Since
not bisectable: every release run fails (5.5.5 on, JDK 9+)introduced 2008-03-26 · over 18 years
Affected
5.5.5 to 10.6.0 and main (8 releases and main run, JDK 25)
Status
documented
Found by
non-formal: by execution, while validating another fix

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

Lucene limits a stored string by its length in characters, but reads it back with a JDK call limited by its length in UTF-8 bytes. For 3-byte characters the byte limit comes at half the character limit.

Background: how a stored string is kept

  1. A stored field keeps the original value, so you can fetch it with your search hits through StoredFields#document.
  2. Lucene writes a stored string as UTF-8: a byte count, then the bytes. ASCII takes 1 byte per character, accented Latin and Cyrillic 2, Chinese, Japanese and Korean 3.
  3. IndexWriter rejects a stored string longer than MAX_STORED_STRING_LENGTH, about 715.8 million characters, so that its UTF-8 form always fits in one byte array.
Diagram of the bug: a stored field of 357,913,941 copies of U+4E00 is 1,073,741,823 UTF-8 bytes. IndexWriter checks characters against MAX_STORED_STRING_LENGTH, about 715.8 million, and accepts and commits it. On read, DataInput.readString calls new String(bytes, UTF_8); the JDK sizes the UTF-16 buffer by the byte count and refuses 2^30 - 1 or more, so StoredFields#document throws OutOfMemoryError 'UTF16 String size is 1073741823'. One character fewer (1,073,741,820 bytes) reads back. The fix decodes large values with UnicodeUtil.UTF8toUTF16.
A 357,913,941-character CJK string is 1,073,741,823 bytes in UTF-8: under IndexWriter's character limit, but at the JDK decoder's byte limit, so the committed document cannot be read back. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Store one text field made of 357,913,941 copies of 一 (U+4E00, 3 bytes in UTF-8): 1,073,741,823 bytes. IndexWriter's limit, MAX_STORED_STRING_LENGTH, is about 715.8 million characters, so the document is accepted and the commit succeeds.
  2. You expect storedFields().document(0) to return the string, as it does with one character fewer (1,073,741,820 bytes).
  3. Lucene turns the stored bytes back into a String with the JDK's new String(bytes, UTF_8). For non-Latin-1 text the JDK sizes its buffer by the byte count and refuses 1,073,741,823 (2^30 - 1) or more, although the result would have only 357.9 million characters.
  4. So every full load of that document throws OutOfMemoryError: UTF16 String size is 1073741823, should be less than 1073741823, with a 4 GB or a 10 GB heap alike.
  5. Anything else that decodes it fails too: an addIndexes merge that copies fields one by one closes IndexWriter, and with an index sort the commit() that flushes it fails, closes the writer and loses the uncommitted documents.

Who is affected

Any application on the default codec that stores one text field of at least 2^30 - 1 UTF-8 bytes containing some non-Latin-1 text, for example about 358 million CJK characters. No crafting is needed, but it takes about 1 GiB in one field and several GB of heap to index; common request-size caps keep it out. Rare.

Mechanism, terms and numbers837 words

IndexWriter limits a stored string by its length in characters: it rejects only strings longer than IndexWriter.MAX_STORED_STRING_LENGTH, about 715.8 million chars, a bound derived from the worst-case UTF-8 encode buffer. Reading the field back goes through DataInput.readString, which calls the JDK's new String(bytes, 0, length, UTF_8). Since JDK 9 that constructor sizes its UTF-16 buffer by the UTF-8 byte count, and on JDK 21 and 25 it refuses 2^30 - 1 or more with OutOfMemoryError before it allocates the UTF-16 buffer (it has only copied the input bytes).

So a stored field of 357,913,941 CJK chars (1,073,741,823 bytes, about half of the accepted maximum) is indexed and committed, and then every StoredFields#document call on that document throws java.lang.OutOfMemoryError: UTF16 String size is 1073741823, should be less than 1073741823, with a 4, 6 or 10 GB heap alike; one char fewer reads back. A visitor-path merge (executed: addIndexes into an index with other field numbers) and, with an index sort, the flush of the segment that holds the document decode the field too, throw the same Error and close IndexWriter. The bytes on disk are valid: a read-side fix in DataInput.readString that decodes large values with Lucene's own UnicodeUtil.UTF8toUTF16 makes existing indexes of the default stored-fields format readable. It needs about 1 GiB in one field with some non-Latin-1 text, so it is rare; every release run on JDK 25, 5.5.5 to 10.6.0 and main, fails.

The bug, step by step (main 645d4fe14a20)

  1. The write check counts chars. IndexingChain throws IllegalArgumentException("stored field ... is too large (... characters) to store") only when storedValue.getStringValue().length() > IndexWriter.MAX_STORED_STRING_LENGTH (IndexingChain.java:1491-1503). The constant is ArrayUtil.MAX_ARRAY_LENGTH / UnicodeUtil.MAX_UTF8_BYTES_PER_CHAR (IndexWriter.java:260-262), 715,827,877 on these JVMs: the largest string whose worst-case UTF-8 form fits in one byte[]. It is an encode-side bound.
  2. The writer stores UTF-8. Lucene90CompressingStoredFieldsWriter.writeField(FieldInfo, String) calls bufferedDocs.writeString(value) (Lucene90CompressingStoredFieldsWriter.java:323-328). bufferedDocs is a ByteBuffersDataOutput (:95, :124), whose writeString override (ByteBuffersDataOutput.java:414-451) writes the format DataOutput.writeString documents (DataOutput.java:263-270): a vInt byte length (:437-438), then the UTF-8 bytes from UnicodeUtil.UTF16toUTF8 (for a value this long through writeLongString, :446, :567-581). A 1,073,741,823-byte value is written and committed without error.
  3. Every read in the default format decodes with the JDK constructor. readField handles a STRING field with visitor.stringField(info, in.readString()) (Lucene90CompressingStoredFieldsReader.java:295-296), so the value is decoded before any visitor sees it; a visitor can only avoid that by answering NO or STOP in needsField (:706-718). DataInput.readString returns new String(bytes, 0, length, StandardCharsets.UTF_8) (DataInput.java:226-231). The non-default SimpleText codec decodes on its own with the same JDK constructor (SimpleTextStoredFieldsReader.java:165; not run).
  4. The JDK refuses by byte count. For a result with non-Latin-1 chars, JDK 25's UTF-8 constructor calls StringUTF16.newBytesFor(length) with the UTF-8 byte count, and StringUTF16.newBytesLength throws OutOfMemoryError when that is >= Integer.MAX_VALUE >> 1 (2^30 - 1), before it allocates the UTF-16 buffer; by then it has only copied the input bytes (JDK 25 String.java:568 copies, :597 calls newBytesFor, StringUTF16.java:53-60 throws; the executed stack shows the frames at :597 and in StringUTF16). The decoded string, 357,913,941 chars, would itself fit. JDK 8 decoded into a char[len] and had no such cap.
  5. Where it surfaces. StoredFields#document; visitor-path merges (MergeStrategy.VISITOR when field numbers differ, Lucene90CompressingStoredFieldsWriter.java:678-684, used at :637-640; executed for addIndexes(CodecReader), other merge configurations read from the code); the index-sort flush, which re-reads every stored field of the new segment through a CopyVisitor that loads every field (SortingStoredFieldsConsumer.java:109-139, :199-200); and CheckIndex's stored-field check. IndexWriter treats any Error as tragic and closes itself (class javadoc IndexWriter.java:162-165; doFlush :4428-4431; addIndexes(CodecReader...) :3391; prepareCommit :3879-3881, the path of the executed sorted commit()).

Which text triggers it

At least 2^30 - 1 UTF-8 bytes, at least one non-Latin-1 char, and (to stay under the char limit) more than 1.5 UTF-8 bytes per char on average: for example about 358M CJK chars, or about 537M Cyrillic chars (arithmetic, not run). Latin-1-only text above the boundary reads back: 536,870,912 x U+00E9 (1,073,741,824 bytes) passes, while the same text with one U+4E00 (1,073,741,825 bytes) fails.

Executed consequences on main

situationresult
StoredFields#document(docId) on the documentOutOfMemoryError every time
loading only another field (the big field skipped), neighbouring documentswork
addIndexes(CodecReader) into an index where the field has another numberOutOfMemoryError; IndexWriter closed, tragic exception = that Error
forceMerge(1) with the document live (chunks copied, no decode)succeeds; the document stays unreadable
index sort configured: commit() after adding the documentOutOfMemoryError; IndexWriter closed; uncommitted documents lost; the last commit intact and CheckIndex-clean
CheckIndex at its command-line default level / at level 2clean / the whole segment broken; -exorcise drops all of its documents (5 of 9 in the probe)
the JVM's ExitOnOutOfMemoryError, CrashOnOutOfMemoryError, OnOutOfMemoryError, HeapDumpOnOutOfMemoryErrornone fires: the Error comes from a size check, not a failed allocation
delete the document by term, then mergeremoved; every remaining document loads
Exposure and likelihood288 words
  • Who can be hit. Any application on the default codec and IndexWriterConfig that stores one text field (StoredField(String); a stored TextField holds the same value, not run, while a StringField this large is already rejected as an immense term) of at least 2^30 - 1 UTF-8 bytes containing a non-Latin-1 char and averaging more than 1.5 bytes per char. Large natural CJK text is enough.
  • Scale. About 1 GiB in one field, and several GB of heap at index time (the test indexes it with a 4 GB heap). Near-GB stored fields do occur: LUCENE-7538 (#8589) started from an 800 MB field, and in the same thread (2017) a user reported files "which are 1GB huge". Common ingestion caps keep it out in practice (for example Elasticsearch's 100 MB default HTTP body limit), and nobody has reported it since JDK 9.
  • What goes wrong. An Error on a document Lucene accepted, not a wrong answer. Unsorted index: the document is committed and stays unreadable; a search that loads all stored fields of its hits fails whenever it is among them; a visitor-path merge closes the writer. Index sort: the flush of the segment that holds it throws, commit() fails, the writer closes and every uncommitted document is lost (the last commit stays intact). CheckIndex does not see it at its default level; -level 2 -exorcise drops the whole segment, other documents included.
  • Default vs opt-in. Default codec and configuration, no special API; the index-sort path is opt-in. 6.x to 8.x are hit only on a JDK 9+ runtime; 9.0.0 and later on every JDK they support (on JDK 11 and 17 from 2^30 bytes rather than 2^30 - 1; source reading, only JDK 25 was run).

Minimal repro

One JUnit test on the public API: one document with one stored string of 357,913,941 x U+4E00, the default codec pinned, a ByteBuffersDirectory, then storedFields().document(0). Deterministic and single-threaded. It needs -Ptests.heapsize=6g only to build and index the 716 MB string; once the heap holds the bytes, the failure does not depend on its size.

TestStoredStringOver1GiBUtf8.java (one test, public API)49 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.index;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestStoredStringOver1GiBUtf8 extends LuceneTestCase {

  public void testReadBackStoredStringOf2Pow30Minus1Utf8Bytes() throws Exception {
    // 2^30 - 1 UTF-8 bytes; expected: document(0) returns it; actual: OutOfMemoryError (readString)
    String value = "\u4e00".repeat(357_913_941);
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w =
          new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
        Document doc = new Document();
        doc.add(new StoredField("f", value));
        w.addDocument(doc);
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        String read;
        try {
          read = r.storedFields().document(0).get("f");
        } catch (OutOfMemoryError e) {
          throw new AssertionError("document(0) threw " + e, e);
        }
        assertEquals(value, read);
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestStoredStringOver1GiBUtf8.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestStoredStringOver1GiBUtf8 -Ptests.heapsize=6g
# assertions off fails the same way: add -Ptests.asserts=false
Expected
storedFields().document(0).get("f") returns the 357,913,941-char string that was indexed and committed.
Observed
OutOfMemoryError: UTF16 String size is 1073741823, should be less than 1073741823 from DataInput.readString, with assertions on and off.

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

More files, environment, full output249 words

The test sits in org.apache.lucene.index but uses only public API. It pins the default codec because LuceneTestCase randomizes it (the gradle "test environment" line still names the class-level random codec; IndexWriterConfig.setCodec overrides it for this writer). The input is the smallest that fails: the boundary run shows 1,073,741,820 bytes reading back and 1,073,741,823 failing, and the text must contain non-Latin-1 chars. Stock fails the same way with a 4 GB and a 10 GB heap; with the default 512 MB test heap it fails earlier, with "Java heap space", while building the string.

As a regression test it is the same test: it fails on stock and passes with either fix. Because it needs a multi-GB heap, upstream would probably mark it @Monster, like Test4GBStoredFields, or @Nightly. Under gradle's default test JVM arguments it passes with 6g with the recommended fix and needs 8g with the alternative one. All runs were on Linux aarch64 with OpenJDK 25.0.3.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on the 10.6.0 and 9.12.3 release jars and on every older release run, back to 5.5.5
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

TestStoredStringOver1GiBUtf8 > testReadBackStoredStringOf2Pow30Minus1Utf8Bytes FAILED
    java.lang.AssertionError: document(0) threw java.lang.OutOfMemoryError: UTF16 String size is 1073741823, should be less than 1073741823
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:3E746858805BB5]:0)
        at org.apache.lucene.index.TestStoredStringOver1GiBUtf8.testReadBackStoredStringOf2Pow30Minus1Utf8Bytes(TestStoredStringOver1GiBUtf8.java:43)

        Caused by:
        java.lang.OutOfMemoryError: UTF16 String size is 1073741823, should be less than 1073741823
            at java.base/java.lang.StringUTF16.newBytesLength(StringUTF16.java:58)
            at java.base/java.lang.StringUTF16.newBytesFor(StringUTF16.java:48)
            at java.base/java.lang.String.<init>(String.java:597)
            at java.base/java.lang.String.<init>(String.java:546)
            at org.apache.lucene.store.DataInput.readString(DataInput.java:230)
            at org.apache.lucene.codecs.lucene90.compressing.Lucene90CompressingStoredFieldsReader.readField(Lucene90CompressingStoredFieldsReader.java:296)
            at org.apache.lucene.codecs.lucene90.compressing.Lucene90CompressingStoredFieldsReader.document(Lucene90CompressingStoredFieldsReader.java:708)
            at org.apache.lucene.index.CodecReader$1.document(CodecReader.java:102)
            at org.apache.lucene.index.BaseCompositeReader$2.document(BaseCompositeReader.java:190)
            at org.apache.lucene.index.StoredFields.document(StoredFields.java:68)
            at org.apache.lucene.index.TestStoredStringOver1GiBUtf8.testReadBackStoredStringOf2Pow30Minus1Utf8Bytes(TestStoredStringOver1GiBUtf8.java:41)

Why it looks unintended

  • MAX_STORED_STRING_LENGTH is documented as the "Maximum length string for a stored field"; this one is about half.
  • That limit was added so a too-large field fails up front "instead of a cryptic exception that closes your IndexWriter"; here a visitor merge or a sorted flush closes it.
  • The decoded string fits in a Java String; Lucene's own UnicodeUtil.UTF8toUTF16 decodes it.
The full argument263 words
  • The contract. MAX_STORED_STRING_LENGTH: "Maximum length string for a stored field." (IndexWriter.java:260). StoredFields#document "Returns the stored fields of the n-th Document" and declares only CorruptIndexException and IOException (StoredFields.java:48-62). DataOutput.writeString and DataInput.readString are documented as a pair (@see, DataOutput.java:263-270, DataInput.java:221-225): the writer side accepts what the reader side refuses.
  • The limit's purpose. LUCENE-7538 (#8589, 6.4.0): "Asking IndexWriter to store a too-massive text field now throws IllegalArgumentException instead of a cryptic exception that closes your IndexWriter" (lucene/CHANGES.txt:8734-8736). In the same issue Michael McCandless answered a user asking for the size limit: "see IndexWriter.MAX_STORED_STRING_LENGTH for the limit; you'll have to either not store that huge file, or break it up across several documents." The visitor merge and the sorted flush here end in the writer-closing outcome that change replaced with an up-front exception.
  • The format's documented limit is larger. The default stored-fields format excludes only documents larger than 2^31 - 2^14 bytes (Lucene90StoredFieldsFormat.java:96-99); this document is about 2^30 bytes. No javadoc names a lower, content- or JDK-dependent limit.
  • No test covers it. The only near-limit test, TestIndexWriter.testMassiveField, is @Ignore("requires running tests with biggish heap") and checks only the rejection above the limit (TestIndexWriter.java:3066-3095).
  • History. The limit was chosen in 2016, when Lucene ran on Java 8, whose decoder had no 2^30 cap; JDK 9's compact strings halved the maximum size of a non-Latin-1 String (JDK-8190429, Won't Fix). The two limits stopped matching then; nothing in the history shows the gap was considered (the Java 8 reading is an inference, not executed).
The devil's-advocate check229 words

The strongest case (the verification reviewers rated it weak; the later fact check by another model family rated it moderate, mainly because a 1 GiB field is operationally extreme): this is a JDK limit, not a Lucene bug; the constant bounds the encode buffer and never promised that every accepted string reads back; gigabyte stored fields are unreasonable (in #8589, Erick Erickson wrote "I question whether it's reasonable to even try to index documents this large"); and IndexWriter's javadoc says it closes itself on any Error.

Why it fails: Lucene chooses both the bound it accepts and the decode call. The cap is a fixed property of every JDK that main supports and, once the heap holds the bytes, does not depend on the heap size, so the failure is deterministic, not an occasional resource problem. The decoded 357.9M-char string is representable (the writer held it) and UnicodeUtil.UTF8toUTF16 decodes it (see the fix). The committer who added the constant presented it to users as the limit of what can be stored; "unreasonable" is an opinion in a comment, not documentation, and the user in that thread answered that their 1 GB files are a valid use case. "Closes on any Error" explains the second symptom, not the first Error. The extreme trigger lowers the priority, which its Low rating reflects; it does not make the behaviour correct.

Suggested fix

Recommended Decode large values with UnicodeUtil.UTF8toUTF16 into an exact-size char[]

In DataInput.readString, for 2^30 - 1 or more bytes, count the UTF-16 characters, decode with Lucene's own UnicodeUtil.UTF8toUTF16 into a char[] of that size and build the String from it. Shorter strings keep today's path, and existing indexes of the default stored-fields format become readable. It passes the repro, 996 tests of the closest suites and :lucene:core:check.

All options and trade-offs3 options · 837 words

Two read-side fixes were run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF, gradle's default test JVM arguments). Both change only DataInput.readString and only for byte lengths of 2^30 - 1 or more. Every stored-string read of the default stored-fields format goes through readString, so they fix all of its executed paths at once (StoredFields#document, the visitor merge, the index-sort flush, CheckIndex) with no format change. They do not touch the non-default SimpleText codec, which decodes on its own (SimpleTextStoredFieldsReader.java:165), and were tested on the default codec only. The closest suites never reach the new branch (nothing in them is near 1 GiB); the repro and the shadow runs do.

Decode large values with UnicodeUtil.UTF8toUTF16 into an exact-size char[] (validated by the checks) (recommended)

In DataInput.readString (DataInput.java:226-231), plus import org.apache.lucene.util.UnicodeUtil;. One counting pass over the bytes finds the UTF-16 length (one unit per lead byte, two for a 4-byte sequence), then Lucene's decoder fills a char[] of exactly that size.

     readBytes(bytes, 0, length);
-    return new String(bytes, 0, length, StandardCharsets.UTF_8);
+    if (length < (Integer.MAX_VALUE >> 1)) {
+      return new String(bytes, 0, length, StandardCharsets.UTF_8);
+    }
+    // String(byte[], UTF_8) sizes its UTF-16 buffer by the byte count and refuses 2^30 - 1 or
+    // more units, although the decoded string is shorter: count the UTF-16 units, then decode
+    int numChars = 0;
+    for (int i = 0; i < length; i++) {
+      final int b = bytes[i] & 0xff;
+      if ((b & 0xc0) != 0x80) { // a lead byte; 4-byte sequences decode to a surrogate pair
+        numChars += b >= 0xf0 ? 2 : 1;
+      }
+    }
+    final char[] chars = new char[numChars];
+    return new String(chars, 0, UnicodeUtil.UTF8toUTF16(bytes, 0, length, chars));
   }

Trade-offs

  • One extra pass over the bytes, only on this rare path. The temporary char[] is 716 MB for this value, next to the 1 GiB byte[] that readString already allocates. Latin-1-only values of 2^30 - 1 or more bytes, which read back today, take the new branch too, so their temporary memory changes as well (a char[] at 2 bytes per char instead of the JDK's own copies); that case was not run with this fix and its heap use was not measured.
  • UnicodeUtil.UTF8toUTF16 does not validate UTF-8 and can throw on invalid input (its javadoc), unlike the JDK decoder, which substitutes malformed input. Lucene's own writers produce valid UTF-8 (the stored-fields writer's ByteBuffersDataOutput.writeString encodes with UnicodeUtil.UTF16toUTF8, which writes unpaired surrogates as U+FFFD) and index files are trusted. But readString is a public DataInput method, so malformed input of that size from any other source (a corrupt file, or data another writer produced) would behave differently.

What the checks validated

  • Stock: the repro fails with assertions on and off (-Ptests.heapsize=6g).
  • Fix: the repro passes at 6g with assertions on and off.
  • The closest suites (all of org.apache.lucene.store.*, TestLucene90StoredFieldsFormat, its HighCompression and MergeInstance variants, TestCompressingStoredFieldsFormat, TestStoredFieldsInt, TestSortingStoredFieldsConsumer, TestStoredFieldsConsumer, TestStoredFieldsMergeReadAdvice, TestIndexWriter, TestUnicodeUtil): 38 classes, 996 tests, 0 failures, 26 skipped.
  • :lucene:core:check -x test passes; with the repro test in the tree, checkGoogleJavaFormat, ecjLintTest and forbiddenApisTest pass. The full core suite was not run.
  • Shadowed onto the stock jars: the hunt's 3 tests, including the addIndexes visitor merge, pass; in the consequences probe all 9 hits load fully and, with an index sort, commit() returns normally and the writer stays open.

The hunt's fix: decode into a char[length] temporary (validated by the checks)

The same branch in DataInput.readString, without the counting loop.

+    if (length < (Integer.MAX_VALUE >> 1)) {
+      return new String(bytes, 0, length, StandardCharsets.UTF_8);
+    }
+    final char[] chars = new char[length];
+    final int numChars = UnicodeUtil.UTF8toUTF16(bytes, 0, length, chars);
+    return new String(chars, 0, numChars);

Trade-offs

Simplest, but the char[length] temporary costs 2 bytes per UTF-8 byte, 2 GiB for this value. Under gradle's default test JVM arguments (-XX:+UseParallelGC) the repro then runs out of heap at 6g and needs 8g.

What the checks validated

Gradle: the repro passes at -Ptests.heapsize=8g with assertions on and off; the same closest suites pass (38 classes, 996 tests, 0 failures, 26 skipped); :lucene:core:check -x test passes. Shadowed onto the stock jars it passed the hunt's 3 tests, the refute reviewer's own repro, and the minimal test at 6 GB with the standalone runner.

Reject the value at index time (not run)

Check the UTF-8 byte length in IndexingChain (only for strings longer than 357,913,940 chars, so normal strings pay nothing), or lower MAX_STORED_STRING_LENGTH to 357,913,940 chars.

Trade-offs

Gives the up-front IllegalArgumentException LUCENE-7538 intended, also for the sorted flush, but leaves already written documents unreadable. A plain byte-length rule, or the lower constant, newly rejects Latin-1 or ASCII strings that read back today; an exact rule would mirror a JDK implementation detail. Complementary to a read-side fix, which alone removes every executed failure.

Downstream note

Not a Lucene security issue; downstream, a service that stores user-supplied text should cap field sizes well below 1 GiB, as common request-size limits already do.

How it was found

Found by execution, not by a model: while validating a fix for a separate stored-fields writer bug, a 2.1 GB stored string failed to read back in DataInput.readString. The boundary was then measured on stock jars (1,073,741,820 bytes read back, 1,073,741,823 fail), and independent checkers reproduced it with their own tests.

The method in full117 words

Method: non-formal (found by execution), a hunt unit on the compressing stored-fields format.

  • A separate finding of the same unit, an int overflow in the stored-fields writer's slice loop for chunks over about 2^31 bytes (LFV-186), was being validated with a patched writer that stored a 2.1 GB String. The read-back failed in DataInput.readString with the JDK's "UTF16 String size" error.
  • An AI tool then measured the boundary on the stock jars and wrote a deterministic JUnit with a control on each side: 357,913,940 chars (1,073,741,820 bytes) read back, 357,913,941 chars (1,073,741,823 bytes) fail.
  • The unit's Z3 model of the stored-fields chunk arithmetic (an obligation on MAX_STORED_STRING_LENGTH) gives context only; it did not produce this signal.

Prior reports, history and checks

Prior reports

No prior report: a 2026-10-26 search of GitHub issues and PRs (Lucene, Elasticsearch, OpenSearch, Solr), the Lucene mailing lists, the JIRA archive, the OpenJDK tracker, advisories and the web, plus the changed files of all 413 open PRs.

Searches5 searches

Closest items: #8589 (LUCENE-7538) added the limit for the write side and never discusses reading the value back; OpenSearch #1651 and #7963 hit the same JDK message in OpenSearch's own response conversion, a different code path; JDK-8190429 records that JDK 9 halved the maximum String size for non-Latin-1 text. Gaps: GitHub search is token-based and was quota-limited, and a generic JDK OutOfMemoryError may have been reported without naming Lucene.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. the migrated JIRA issues)2026-10-2638 GraphQL queries plus the search tool's, e.g. readString OutOfMemoryError, "UTF16 String size", "should be less than 1073741823", MAX_STORED_STRING_LENGTH, "large stored field", "compact strings", StringUTF16, testMassiveField#8589 (LUCENE-7538, the limit, write side only; issue and all comments read), #6907 (writer-side per-document byte limit, open), #7837 (writer memory), #13277 (IndexWriter treats Error as tragedy); none is this bug
Open PRs2026-10-26changed files of all 413 open PRs; diffs of the 44 that touch the files involvednone changes readString, MAX_STORED_STRING_LENGTH or stored-string decoding
Downstream (Elasticsearch, OpenSearch, Solr)2026-10-2615 GraphQL queriesOpenSearch #1651 and #7963 / #8344: the same JDK limit in OpenSearch's own code, a different path
Mailing lists (dev@, java-user@, issues@, commits@ and the Solr lists) and the JIRA archive2026-10-26lists.apache.org and JIRA REST queries for the error message, the constant, readString with OutOfMemoryError, compact stringsonly LUCENE-7538 is relevant; the rest are unrelated JDK 9 early-access threads
OpenJDK tracker, GitHub Advisory Database, web2026-10-26the exact JDK message, Lucene, Elasticsearch and Solr variants, Lucene advisoriesJDK-8190429 (the JDK root, Won't Fix); only the unrelated replicator advisory CVE-2024-45772; nothing about Lucene stored fields

History

Not bisectable: every release run on JDK 25 fails, 5.5.5 to 10.6.0 and main. The 2008 decode call broke when JDK 9 halved the maximum non-Latin-1 String; the 2016 limit (LUCENE-7538, 6.4.0) states the contract it breaks. Not fixed upstream.

Versions tested7 rows

No Lucene commit introduced the failure. It needs three ingredients from different changes:

  1. Stored strings are decoded with the JDK's String(byte[], ..., UTF-8) constructor since LUCENE-510 (8af3598b74d, 2008, first released in 2.4.0); the decode site moved with LUCENE-5989 (5.2.0) and LUCENE-8805 (5a694ea26ff, 9.0.0, into DataInput.readString), each time keeping the same constructor.
  2. JDK 9+ compact strings size the UTF-16 buffer by the byte count and refuse about 2^30 or more units (JDK 21 and 25 from 2^30 - 1; JDK 11 and 17 above it; from the OpenJDK sources, only JDK 25 run). -XX:-CompactStrings does not avoid it (that branch calls the same newBytesFor(length), JDK 25 String.java:606). JDK 8 had no such cap.
  3. LUCENE-7538 (2902727a157 / be47009ce76, 2016-11-10, first released in 6.4.0) added MAX_STORED_STRING_LENGTH, the limit the bug violates. It did not create the failure: 6.3.0, without the limit, fails identically.

From 5.2.0 (LUCENE-5989) to 8.x a StoredFieldVisitor received the raw bytes (5.1.x and earlier passed a decoded String), so an application could decode the field itself; LUCENE-8805 moved the decode into the codec reader, and from 9.0.0 on no visitor can avoid it. readString is unchanged on upstream main 2f424fd428da (2026-10-30), and was identical on branch_10x, branch_10_6, branch_10_5 and branch_9_12 on 2026-10-26.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedfails, assertions on and off (gradle and the standalone runner)
main a9779ef3c193, ea57360912ed; branch_10x 657014b3c579 affectedthe hunt's 3-test repro fails 2 of 3, assertions on and off
10.6.0 affectedthe minimal test fails
10.5.2, 9.12.3 affectedfail (9.12.3 also with the minimal test)
9.0.0 affectedfails (a port of the test to the 9.0 API); the first release that decodes inside the codec reader
8.11.4, 6.4.0 affectedfail (a port to the pre-9 API); a custom visitor that decodes the raw bytes reads the value back
6.3.0, 5.5.5 affectedfail the same way, before the limit existed; the raw-bytes visitor reads the value back

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-verified on 645d4fe14a20 and branch_10x.

Checks and verdicts249 words

Each checker wrote its own tests from the claim; the adjudication re-ran one of them with a new seed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate3-test JUnit (control, boundary, addIndexes visitor merge): 2 of 3 fail on main, 10.5.2 and 9.12.3 with assertions on and off; the control passes; the read-side fix makes all 3 pass.
refute review (OpenAI GPT-5.6) real, highIts own main() fails at 357,913,941 chars and passes at 357,913,940, with assertions on and off, and passes with the fix; intended reading weak.
Independent reproducer realOwn 5-test JUnit: 3 fail on main, 10.5.2 and 9.12.3; both controls pass, including Latin-1-only text above the boundary; located the size check in the JDK 25 sources.
Works-as-designed review (OpenAI GPT-5.6) real, mediumReading only, so the reviewer ran no Java; intended argument weak, root cause agreed. A re-run of the finder's 3-test repro recorded alongside it fails 2 of 3, with assertions on and off.
Intended-behaviour devil's advocate realHigh confidence that it is not intended, low severity; examined five intended readings and rejected each; re-ran the boundary tests with a new seed.
adjudication confirmed, highRe-ran the independent reproduction's tests with seed 5EED1234 (3 of 5 fail, both controls pass) and checked the root cause in the source.
re-verification and this doc's runs still reproducesMain 645d4fe14a20 and branch_10x (2026-10-29); the minimal test on main, 10.6.0 and 9.12.3; two fixes validated through gradle.

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

Finding record

LFV-172, snapshot of 2026-11-06

Registry title
IndexWriter accepts a stored string of 2^30 - 1 or more UTF-8 bytes of non-Latin-1 text (from about half of its own MAX_STORED_STRING_LENGTH up), but DataInput.readString can never decode it, so StoredFields#document, visitor-path merges and index-sorted flushes throw OutOfMemoryError
Area
core store + index / stored string fields (DataInput.readString decode, IndexWriter.MAX_STORED_STRING_LENGTH check)
Priority
Low, rank 962: A stored string of at least 2^30-1 UTF-8 bytes of non-Latin-1 text is accepted but can never be read back (OutOfMemoryError, and a tragic flush under index sorting), reachable with one ~1 GiB field.
Impact class
crash
Impact note
Any application on the default codec and configuration that stores one text field of at least 2^30 - 1 UTF-8 bytes containing a non-Latin-1 character (for example about 358M CJK chars, roughly half of IndexWriter.MAX_STORED_STRING_LENGTH of about 715.8M chars) is hit: the document is accepted and committed, but every full stored-field load of it throws java.lang.OutOfMemoryError whatever the heap size, CheckIndex -level 2 flags its whole segment, and visitor-path merges (addIndexes into an index with other field numbers [exec]; SortingCodecReader [read]) and, with an index sort, the next flush [exec] throw the same Error and close IndexWriter, losing uncommitted documents while the last commit stays intact; extreme scale (about 1 GiB in one field, several GB of heap at index time), JDK 9+ runtimes, executed on JDK 25 only.
Summary
You store a text field of 357,913,941 CJK characters (1,073,741,823 UTF-8 bytes, about half of what IndexWriter's own length check, MAX_STORED_STRING_LENGTH, allows) with the default codec, and IndexWriter accepts and commits it. You expect StoredFields#document to return it, as it does for a string one character shorter. Instead every read throws java.lang.OutOfMemoryError 'UTF16 String size is 1073741823, should be less than 1073741823', whatever the heap, because DataInput.readString decodes with new String(bytes, 0, length, UTF_8), which on JDK 9 and later sizes its UTF-16 buffer by the byte count and refuses 2^30 - 1 or more units; merges that copy the document through a visitor, and flushes of an index-sorted writer, throw the same Error and close IndexWriter.
Introduced
none bisectable: no release executed on JDK 25 passes (5.5.5 to main) [exec]. Three ingredients [read: follow-up]: (1) stored strings are decoded with the JDK String(byte[], ..., UTF-8) constructor since 8af3598b74d (LUCENE-510, first released 2.4.0); the decode site moved with LUCENE-5989 (fd23211957a / 249d0d25fec, 5.2.0) and LUCENE-8805 (5a694ea26ff, 9.0.0, into DataInput.readString), keeping the same constructor; (2) JDK 9 compact strings size the UTF-16 buffer of a non-Latin-1 result by the UTF-8 byte count and refuse about 2^30 or more units (OpenJDK source reading; JDK 8 decodes into a char[] with no such cap); (3) the documented limit IndexWriter.MAX_STORED_STRING_LENGTH and its IndexWriter check, 2902727a157 (master) / be47009ce76 (branch_6x), LUCENE-7538 (#8589), first released 6.4.0, which states the contract but did not introduce the failure (6.3.0, without the limit, fails identically [exec]) (none bisectable; ingredient dates: 2008-03-26 (LUCENE-510, decode by the JDK constructor), 2016-11-10 (LUCENE-7538, the stated limit), 2017 (JDK 9 GA with compact strings; general knowledge, not checked))
First affected version
<= 5.5.5 [exec on JDK 25] (the oldest release executed; by code reading every release since 2.4.0 decodes stored strings through the same JDK constructor; the documented limit the bug violates exists since 6.4.0; 6.x-8.x are affected only on a JDK 9+ runtime, 9.0.0 and later on every JDK they support [read])
Affected versions
5.5.5, 6.3.0, 6.4.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2, 10.6.0 and main ea57360912ed [exec 2026-10-31: the minimal test fails on the 10.6.0 release jars (SHA-1 checked) with -ea and -da and on 9.12.3, with the same OutOfMemoryError; exec, the finder's test: JDK 25.0.3, seed DEADBEEF, -ea, -Xmx10g, SHA-1-verified release jars; each fails the read-back and the addIndexes visitor merge with the same OutOfMemoryError, and the 1,073,741,820-byte control passes]; main also with -da [exec]; main a9779ef3c193 and 645d4fe14a2 and branch_10x 657014b3c579 [exec 2026-10-29: 3 run / 2 fail with -ea and -da, same message]; every release from 2.4.0 on [read: same JDK decode call on every stored-string read path]; before 9.0.0 an application's own raw-bytes StoredFieldVisitor could still read the field [exec on 5.5.5, 6.3.0, 6.4.0, 8.11.4], from 9.0.0 on no visitor can [read]; JDK dependence [read]: 6.x-8.x fail only on JDK 9 or later, JDK 21 and 25 refuse from 2^30 - 1 bytes, JDK 11 and 17 above 2^30 - 1 (exactly 2^30 - 1 not checked); executed on JDK 25 and aarch64 only; not fixed upstream (readString identical on main, branch_10x, branch_10_6, branch_10_5 and branch_9_12, 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
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: 162 logged query lines: a scripted prior-report search over apache/lucene issues, PRs and commits, the Elasticsearch, OpenSearch and Solr trackers, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 38 GitHub GraphQL searches over apache/lucene and 15 over the downstream repositories; 16 lists.apache.org, 12 JIRA REST and 1 OpenJDK JIRA queries; the GitHub Advisory Database for five lucene artifacts (only CVE-2024-45772, replicator, unrelated); the changed-file lists of all 413 open apache/lucene PRs (44 touch the involved files, their diffs were checked, none changes readString, MAX_STORED_STRING_LENGTH or stored-string decoding); a local full-history git search; plus the finder's 16 GitHub and 4 JIRA queries and the intended-behaviour check's 20 list, 4 JIRA and 14 GitHub queries): no issue, PR, commit, review comment, list post, downstream item or public advisory reports it; nearest items: #8589 (LUCENE-7538, which added MAX_STORED_STRING_LENGTH; write side only, issue and all 16 comments read), #6907 (LUCENE-5845, open, writer-side per-document byte limit), #7837 (LUCENE-6779, writer memory), OpenSearch #1651 and #7963 (the same JDK message in OpenSearch's own response-conversion code, a different code path), elastic/elasticsearch #90861 (canonical strings from readString, unrelated), JDK-8190429 (JDK 9 halved the maximum String size for non-Latin-1 text, Won't Fix); not fixed upstream; gaps: GitHub search is token-based and capped at 50 results, GitHub's search rate limit was exhausted repeatedly (GraphQL used instead), a generic JDK OutOfMemoryError may have been reported without naming Lucene, and a private security report would not be visible
Lucene commit tested
main 645d4fe14a20 (gradle and the standalone runner, assertions on and off); the release jars of 10.6.0 and 9.12.3 with the minimal test, and of 10.5.2, 9.0.0, 8.11.4, 6.4.0, 6.3.0 and 5.5.5 with the hunt's test and its ports (all fail); two read-side fixes through gradle in a scratch worktree of 645d4fe14a20. All runs on Linux aarch64 with OpenJDK 25.0.3; no other JDK was run. Upstream main 2f424fd428da (2026-10-30) has the same code.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/store/DataInput.java:226-231 (readString: 'return new String(bytes, 0, length, StandardCharsets.UTF_8);')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1436-1449 (rejects a stored STRING only when length() in chars > MAX_STORED_STRING_LENGTH, IllegalArgumentException 'is too large (... characters) to store')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsReader.java:295-296 ('case STRING: visitor.stringField(info, in.readString());'), :704-716 (a field the visitor answers NO for is skipped without decoding); Lucene90CompressingStoredFieldsWriter.java:637-643 (VISITOR merge strategy: reader.document(sub.docID, visitors[...]))
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/SortingStoredFieldsConsumer.java:109-139 (index-sort flush re-reads every document through a CopyVisitor), :199-200 (CopyVisitor.needsField returns YES)
  • branch_10x 657014b3c579 line numbers: DataInput.java:230-235 (readString), IndexWriter.java:260 (MAX_STORED_STRING_LENGTH), IndexingChain.java:1435 (char-length check), Lucene90CompressingStoredFieldsReader.java:297 (STRING read)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1491-1503; index/IndexWriter.java:162-165, 260-262, 3391, 3879-3881, 4428-4431; util/ArrayUtil.java:33-34; util/UnicodeUtil.java:128
  • lucene/core/src/java/org/apache/lucene/store/DataInput.java:226-231; store/DataOutput.java:263-270; store/ByteBuffersDataOutput.java:414-451, 567-581
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsReader.java:295-296, 706-718; Lucene90CompressingStoredFieldsWriter.java:95, 124, 323-328, 637-640, 678-684; lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextStoredFieldsReader.java:158-165
  • lucene/core/src/java/org/apache/lucene/index/SortingStoredFieldsConsumer.java:109-139, 199-200
Who is affected
  • GitHub apache/lucene #8589 (LUCENE-7538): the 800 MB report and the 2017 comment about 1 GB files (re-read 2026-10-31 with gh issue view)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2153-2168 (invertTerm: a StringField is indexed as one binary term, StringField.java:90-91, and a term over the length limit throws 'Document contains at least one immense term' before the stored value is written; reasoning, not run)
Minimal repro
  • lucene/core/src/test/org/apache/lucene/index/Test4GBStoredFields.java:35-38 (@Monster precedent)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:260; index/StoredFields.java:48-62; store/DataOutput.java:263-270; store/DataInput.java:221-225
  • lucene/CHANGES.txt:8734-8736 (LUCENE-7538)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90StoredFieldsFormat.java:96-99
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:3066-3095
  • GitHub apache/lucene #8589: comments by Erick Erickson (JIRA comment 16202707) and the reporting user's reply (16208235), re-read 2026-10-31 with gh issue view
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java:631-672 (UTF8toUTF16, no validation, javadoc :631-637), :199 and :231-235 (UTF16toUTF8 for a CharSequence, U+FFFD for unpaired surrogates); store/ByteBuffersDataOutput.java:414-451, 567-581; util/BytesRef.java:79-82
  • SECURITY.md line 32 (index files are trusted)
History
  • a Lucene clone: git diff 645d4fe14a20 origin/main (2f424fd428da) over DataInput.java, IndexingChain.java, IndexWriter.java, SortingStoredFieldsConsumer.java and codecs/lucene90/compressing/ is empty (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, the OpenJDK 25.0.3 sources and a diagram probe run on main (assertions on and off); every value on screen was printed by that probe or by the minimal-repro runs. The diagram's fix sketch is the simpler alternative fix (a char[length] temporary); the recommended fix differs only in sizing the char[] exactly.

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.