All findings · Lucene finding · LFV-330 · memoryindex-stored-ignores-storedvalue

MemoryIndex returns stored StringField, KeywordField and numeric field values as bytes, because it stores binaryValue() instead of storedValue()

Read a stored StringField("id", "doc-1", Store.YES) back from a MemoryIndex and Document.get("id") returns null; a stored IntField 5 comes back as the bytes [-128, 0, 0, 5]. Since 9.6.0, for these fields MemoryIndex silently stores binaryValue(), the indexed term or point, instead of storedValue().

Summary

Priority
Medium, rank 237Since 9.6.0, MemoryIndex deterministically and silently returns stored StringField and numeric values with the wrong type, but only for the niche pattern of reading stored fields from it, so it heads the narrow silent rows after LFV-237.
Impact
silent wrong results
Since
9.6.0 (#12116 and #12139, 2023); the DataInput case 10.2.0introduced 2023-02-06 · about 3 years
Affected
9.6.0 to 10.5.2 and main
Status
documented
Found by
translation validation of MemoryIndex against IndexWriter on the real jar

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

MemoryIndex holds one document in memory, for percolator- and Monitor-style matching. Since 9.5.0 it can also return that document's stored fields, and they should match what IndexWriter returns.

Diagram of the bug. One document with StringField("id", "doc-1", Store.YES) and IntField("count", 5, Store.YES) goes into an IndexWriter index and into a MemoryIndex. IndexWriter stores storedValue() and returns "doc-1" and 5. MemoryIndex stores binaryValue() and returns the bytes [100, 111, 99, 45, 49] and [-128, 0, 0, 5], so Document.get("id") and numericValue() return null.
One document read back two ways: IndexWriter stores what the field says to store ("doc-1", 5), while MemoryIndex keeps the field's binary form, the indexed term and the point bytes, so get and numericValue() return null. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You build a MemoryIndex from one document with StringField("id", "doc-1", Store.YES) and IntField("count", 5, Store.YES) and read it back with createSearcher().storedFields().document(0).
  2. You expect what an IndexWriter index returns for the same document: get("id") is "doc-1" and getField("count").numericValue() is 5.
  3. Instead both are null. The values came back as bytes: [100, 111, 99, 45, 49] ("doc-1" in UTF-8) and [-128, 0, 0, 5] (the sortable point encoding of 5). KeywordField, LongField, FloatField and DoubleField do the same, and a StoredField built from a StoredFieldDataInput is not stored at all.
  4. Why: to store a field, MemoryIndex takes binaryValue() if it is non-null, which for these fields is the indexed term or point. IndexWriter has asked the field for its storedValue() since 9.6.0; MemoryIndex was never moved to it. No error is raised, and query matching is unaffected.

Who is affected

Code that puts a stored StringField/KeywordField built from a String, or an IntField/LongField/FloatField/DoubleField with Store.YES, into a MemoryIndex (directly, through fromDocument, or as a one-document Monitor batch) and reads stored fields back, for example to show or highlight a matched document. Default settings and stock fields; an uncommon pattern, but then every document is hit.

Mechanism, terms and numbers591 words

MemoryIndex, Lucene's one-document in-memory index, has supported stored fields since 9.5.0 (#11999). It does not ask a field what to store (IndexableField#storedValue(), which IndexWriter uses since 9.6.0, #12116); it keeps binaryValue() if that is non-null, else numericValue(), else stringValue(). For StringField and KeywordField built from a String, binaryValue() is the indexed term, and for IntField, LongField, FloatField and DoubleField it is the sortable point encoding. So these stored values come back as bytes, and Document.get or numericValue() returns null; a StoredField(String, StoredFieldDataInput) is dropped. IndexWriter returns the String, the number and the bytes for the same document. No error is raised and query matching is unchanged. Every release from 9.6.0 to 10.5.2 and main is affected; the fix is a small change to MemoryIndex.storeValues that uses storedValue().

Terms

  • MemoryIndex (lucene/memory): a one-document index in memory. You add fields with addField(IndexableField, Analyzer) or build it with MemoryIndex.fromDocument(doc, analyzer), then search it through createSearcher(). Lucene Monitor uses one for every one-document batch.
  • Stored fields: the original values kept with Field.Store.YES, read back with storedFields().document(docId). #11999 added them to MemoryIndex in 9.5.0.
  • binaryValue() vs storedValue(): an IndexableField exposes its value through several getters. binaryValue() also serves indexing: for StringField/KeywordField it is the term to index, for IntField and the other numeric fields the encoded point. storedValue() (9.6.0, #12116) is the one value meant for stored fields: the String, the number or the bytes.

The bug

  1. addField stores a field whose type says stored() by calling storeValues (MemoryIndex.java:673-675 on main a9779ef3c193).
  2. storeValues (MemoryIndex.java:867-885) keeps field.binaryValue() if it is non-null, else numericValue(), else stringValue(). It never calls storedValue().
  3. StringField(String, Store) sets binaryValue = new BytesRef(value) and a String storedValue (StringField.java:59-66); KeywordField(String, Store) the same (KeywordField.java:96-103). IntField.binaryValue() returns NumericUtils.intToSortableBytes of the value (IntField.java:84-88); LongField, FloatField and DoubleField likewise. So storeValues keeps bytes.
  4. A StoredField(String, StoredFieldDataInput) returns null from all three old getters (Field.java:264-270, :419-434); only storedValue() exposes it (Field.java:607-624). So nothing is kept.
  5. The stored-fields reader replays each kept object by its runtime type (MemoryIndex.java:2242-2273): a BytesRef becomes a binary field, so Document.get and numericValue() return null.
  6. IndexWriter instead stores field.storedValue() and throws "Cannot store a null value" when it is null (IndexingChain.java:1491-1505).

The numbers

field, Store.YESIndexWriter returnsMemoryIndex returns
StringField("id", "doc-1")String "doc-1"binary [100, 111, 99, 45, 49], get("id") = null
KeywordField("tag", "red")String "red"binary [114, 101, 100], get("tag") = null
IntField(5)Integer 5binary [-128, 0, 0, 5]
LongField(7)Long 7binary [-128, 0, 0, 0, 0, 0, 0, 7]
FloatField(1.5f)Float 1.5binary [-65, -64, 0, 0]
DoubleField(2.5)Double 2.5binary [-64, 4, 0, 0, 0, 0, 0, 0]
StoredField(StoredFieldDataInput [9, 8, 7])binary [9, 8, 7]absent

Not affected (both agree): StringField(BytesRef), KeywordField(BytesRef), a StoredField built from a String, a number or bytes (tested: StoredField(String), StoredField(int), StoredField(BytesRef)), TextField(Store.YES). One expert constructor differs the other way round: for StoredField(String, CharSequence, FieldType) with a CharSequence that is not a String (a StringBuilder), IndexWriter throws, since Field.storedValue() throws IllegalStateException (Field.java:607-624), while MemoryIndex stores its toString() (executed in the fact check). For the String and numeric cases the type is wrong, but the bytes are the field's own UTF-8 term or point encoding and decode back to the value (executed for the example values "doc-1" and 5); only the StoredFieldDataInput value is lost.

Exposure and likelihood275 words
  • Who. Applications that read stored fields back from a MemoryIndex searcher: percolator- or Monitor-style matching that displays or highlights the matched document, or a custom Monitor CandidateMatcher that reads the document. #11999 added stored fields for exactly that use. A one-document Monitor batch is a MemoryIndex (DocumentBatch.java:44-46, :55-63, :103-114).
  • Default vs opt-in. Stock field classes with Store.YES, the documented way to keep a value; no special setting. Only the StoredFieldDataInput case needs an @lucene.experimental API (10.2.0 on).
  • Every time, silently. The outcome depends only on the field class and constructor: each affected class gave the same wrong form for all 100 random and edge values tried. No exception, no warning. (This probe and the next two ran on main ea57360912ed; lucene/memory and lucene/monitor are unchanged from there to a9779ef3c193.)
  • Inconsistent results. In Monitor, a matcher that read a stored StringField("tenant", "t1", Store.YES) got null in a one-document batch and "t1" in a two-document batch (built with IndexWriter), for the same matching queries.
  • It can spread. Copying the MemoryIndex reader into a real index with IndexWriter.addIndexes(SlowCodecReaderWrapper.wrap(...)) succeeds, CheckIndex reports it clean, and the copy keeps the wrong types.
  • How bad. Wrong type, not lost data, for the String and numeric cases tested (the bytes are the field's own term or point encoding and decode back to the value); the StoredFieldDataInput value is lost. Matching is unchanged.
  • How common. No in-tree module reads stored fields from a MemoryIndex (the highlighter fills one from token streams only), and no report was found in the recorded searches; one public question (2024) adds LongField(..., Store.YES) to a MemoryIndex but never reads it back.

Minimal repro

The test shows that MemoryIndex stores a StringField(String, Store.YES) as its term bytes (binaryValue()) instead of its storedValue(), so the stored String is lost.

TestMemoryIndexStoredStringField.java (JUnit test)34 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.memory;

import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestMemoryIndexStoredStringField extends LuceneTestCase {

  public void testStoredStringFieldRoundTrips() throws Exception {
    Document doc = new Document();
    doc.add(new StringField("id", "doc-1", Field.Store.YES));
    MemoryIndex index = MemoryIndex.fromDocument(doc, new StandardAnalyzer());
    // IndexWriter returns "doc-1" for this document; MemoryIndex returns null
    assertEquals("doc-1", index.createSearcher().storedFields().document(0).get("id"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-330/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.memory.TestMemoryIndexStoredStringField
Expected vs actual
Expected: storedFields().document(0).get("id") is "doc-1", as with IndexWriter. Actual: null.

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

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<doc-1> but was:<null>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.index.memory.TestMemoryIndexStoredStringField.testStoredStringFieldRoundTrips(TestMemoryIndexStoredStringField.java:32)

Why it looks unintended

  • #12116 added storedValue() because guessing from binaryValue() picks the wrong value for keyword fields, and moved IndexWriter to it; MemoryIndex was missed.
  • StringField(String, Store.YES) round-trips on 9.5.0 and fails on 9.6.0, broken by an indexing speed-up (#12139).
  • CHANGES 9.5.0: "MemoryIndex now supports stored fields"; its test asserts typed round trips, but only with StoredField.
The full argument340 words
  • The contract. CHANGES 9.5.0: "GITHUB#11999: MemoryIndex now supports stored fields." (CHANGES.txt:3768). IndexableField#storedValue(): "Stored value. This method is called to populate stored fields and must return a non-null value if the field stored." (IndexableField.java:74-78). Field.Store.YES: "The value is stored in its original form, i.e. no analyzer is used before it is stored." (Field.java:596-599). CHANGES 9.6.0: "GITHUB#12116: Introduce IndexableField#storedValue() to expose the value that should be stored to IndexingChain without needing to guess the field's type." (CHANGES.txt:3569-3571).
  • History: an oversight. The #12116 commit message (96136b42821) describes this exact failure: binaryValue() "is used both for sorted(_set) doc values and storage. This makes it impossible to implement keyword fields that is stored and doc-valued, as the field returns a non-null value for both binaryValue() and stringValue() and stored fields no longer know which field to store". It adds storedValue() so that a stored fields format cannot "mistakenly use binaryValue()/stringValue()/numericValue() instead of storedValue()". In lucene/memory it changed only TestMemoryIndex.java. No PR thread of #12116, #12054, #12139 or #14213 mentions MemoryIndex. #12139 did change MemoryIndex.java (cce33b07e4f, +30 lines): addField now indexes a field without a TokenStream by taking binaryValue() as its single term (a new storeTerm), with a new TestMemoryIndex.testKeywordWithoutTokenStream. It did not change storeValues: the indexing path was adapted to the new meaning of binaryValue(), the storing path was not.
  • A regression for StringField. StringField(String, Store.YES) round-trips on 9.5.0 and fails on 9.6.0. On the 9.6.0 jars, StringField.java from just before the #12139 backport passes and from #12139 ("Faster indexing of string fields") fails.
  • Tests. TestMemoryIndex.testStoredFields (TestMemoryIndex.java:719-766) asserts that numbers come back as numbers, strings as strings and bytes as bytes, but only with StoredField, whose binaryValue() is null for non-binary values. No test asserts the binary form for StringField, KeywordField or IntField.
  • Precedent. #12109 ("MemoryIndex should not fail integer fields that enable doc values") fixed another MemoryIndex/IndexingChain divergence for the new IntField as a bug: it "aligns MemoryIndex with IndexingChain".
The devil's-advocate check224 words

The strongest works-as-designed case, built by an intended-behaviour verifier and by a review from another model family: the MemoryIndex class javadoc says a query selects "on the field names and associated (indexed) tokenized terms, not on the original fulltext(s) - the latter are not stored but rather thrown away immediately after tokenization" (MemoryIndex.java:133-135); MemoryIndex never promises equivalence with IndexWriter; and StoredFieldDataInput is @lucene.experimental (StoredFieldDataInput.java:27).

Why it fails: that sentence is from 2005 (the original contribution 8f9e2a15e78 says "free text(s)"; 0062898ada6 reworded it later that year) and predates stored-field support; #11999 then made stored fields a documented feature with a type-preserving test. The behaviour changed in 9.6.0 through an unrelated indexing speed-up, and nothing documents a reduced stored-field contract. For StoredFieldDataInput, neither its javadoc nor #14213 limits it to IndexWriter, and MemoryIndex drops it silently instead of storing it or throwing. Verdict: not intended, a defect. Expect the class-javadoc sentence to be quoted back and answer with the #11999 CHANGES entry and testStoredFields; a reviewer may also read #12139's MemoryIndex edit as a sign that only indexing was meant to follow the new binaryValue(), though nothing documents that stored values were meant to change. Expect pushback mainly on priority (stored fields in MemoryIndex were accepted reluctantly in #11999) and on compatibility for custom fields (see the fix trade-offs).

Suggested fix

Recommended Store storedValue(), mapped by type; keep the old guess only when it is null

In MemoryIndex.storeValues, store field.storedValue() and keep it by StoredValue.Type, copying a DATA_INPUT value into a BytesRef; keep the old guess only for a field whose storedValue() is null (+30 lines). The repro passes 8 of 8 with assertions on and off, and all lucene/memory and lucene/monitor tests pass.

StoredValue storedValue = field.storedValue();
if (storedValue != null) {
  switch (storedValue.getType()) {
    case INTEGER -> info.storedValues.add(storedValue.getIntValue());
    case LONG -> info.storedValues.add(storedValue.getLongValue());
    case FLOAT -> info.storedValues.add(storedValue.getFloatValue());
    case DOUBLE -> info.storedValues.add(storedValue.getDoubleValue());
    case STRING -> info.storedValues.add(storedValue.getStringValue());
    case BINARY -> info.storedValues.add(storedValue.getBinaryValue());
    case DATA_INPUT -> info.storedValues.add(readBytes(storedValue.getDataInputValue()));
  }
  return;
}
// ... the old binaryValue() / numericValue() / stringValue() guess, unchanged
All options and trade-offs4 options · 772 words

Two variants of the same fix were applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF); they differ only in what happens to a stored field whose storedValue() is null.

Store storedValue(), mapped by type; keep the old guess only when it is null (validated by the checks) (recommended)

In storeValues, take field.storedValue() and add its value by StoredValue.Type; a DATA_INPUT value is read into a new BytesRef of length() bytes by a small readBytes helper (an IOException becomes an UncheckedIOException, since addField declares none). If storedValue() is null (IndexWriter rejects such a field), fall through to today's binaryValue() / numericValue() / stringValue() guess. +30/-0 in MemoryIndex.java.

StoredValue storedValue = field.storedValue();
if (storedValue != null) {
  switch (storedValue.getType()) {
    case INTEGER -> info.storedValues.add(storedValue.getIntValue());
    case LONG -> info.storedValues.add(storedValue.getLongValue());
    case FLOAT -> info.storedValues.add(storedValue.getFloatValue());
    case DOUBLE -> info.storedValues.add(storedValue.getDoubleValue());
    case STRING -> info.storedValues.add(storedValue.getStringValue());
    case BINARY -> info.storedValues.add(storedValue.getBinaryValue());
    case DATA_INPUT -> info.storedValues.add(readBytes(storedValue.getDataInputValue()));
  }
  return;
}
// ... the old binaryValue() / numericValue() / stringValue() guess, unchanged

Trade-offs

  • The smaller of the two changes, because it keeps today's guess when storedValue() returns null: a custom stored IndexableField with a null storedValue() keeps working in MemoryIndex as today, although IndexWriter rejects it.
  • A and B both change a stored field whose storedValue() throws: StoredField(name, aStringBuilder, StoredField.TYPE) now throws IllegalStateException in MemoryIndex, as it already does in IndexWriter; today MemoryIndex stores its toString() (executed in the fact check, stock and with A). A custom field whose storedValue() differs from its old getters changes too.
  • Cost: the DATA_INPUT copy reads the input once, the same work IndexWriter's stored-fields writer does; BINARY values stay by reference, as today; the other types are boxed as before.
  • Not tested with either fix: over-long stored strings, reuse of a BytesRef after addField returns, a StoredFieldDataInput that fails to read, custom fields with several non-null getters.

What the checks validated

  • Stock main a9779ef3c193, gradle, seed DEADBEEF: the repro fails 7 of 8 with assertions on and with assertions off; the control passes.
  • With A: the repro passes 8 of 8 with assertions on and with assertions off; all of lucene/memory passes (59 tests in 4 classes: TestMemoryIndex 30, TestMemoryIndexAgainstDirectory 16, the repro 8, TestSlicedIntBlockPool 5, per the fact check's JUnit XML) and all of lucene/monitor (181 tests, 1 skipped), and :lucene:memory:checkGoogleJavaFormat and :lucene:memory:ecjLint pass.
  • The classpath runner with A shadowed on main: 8 of 8 pass. The hunt's earlier sketch of the same idea (it also copies BINARY values) passed the finder's repro, an independent 11-case suite (8 defect cases, 3 controls) and 51 upstream memory tests on ea57360912ed.

Store storedValue() and mirror IndexWriter's null check (validated by the checks)

Same as the recommended option, but throw new IllegalArgumentException("Cannot store a null value") instead of the fallback; the old guess is removed. +28/-13 in MemoryIndex.java. It does not copy IndexWriter's other behaviour: there is no IndexWriter.MAX_STORED_STRING_LENGTH check (IndexingChain.java:1495-1502), and like A and today's code it keeps a BINARY value by reference, while IndexWriter's stored-fields writer copies the bytes when the document is added (Lucene90CompressingStoredFieldsWriter.java:303-319).

Trade-offs

One null rule for both writers and less code; #12116 made IndexWriter fail early on such a field ("If there is a problem ... it's currently discovered late"). But a custom stored field with a null storedValue() that works in MemoryIndex today would start to throw, so it is a visible behaviour change for such callers.

What the checks validated

  • With B: all of lucene/memory passes (59 tests, the repro included) and all of lucene/monitor (181 tests, 1 skipped); format and lint pass. So no path that these suites ran with seed DEADBEEF adds a stored field with a null storedValue() to a MemoryIndex (B would throw).

Add the dual-purpose stock fields to the tests (with either fix)

Add StringField/KeywordField from a String, the four numeric fields and a StoredFieldDataInput to TestMemoryIndex.testStoredFields, or compare stored fields in TestMemoryIndexAgainstDirectory; this doc's repro can serve as the test.

Trade-offs

testStoredFields used only StoredField, whose binaryValue() is null for non-binary values, which is why the drift went unnoticed.

Workaround for users today: Store.NO plus a separate StoredField

Index with Store.NO and add a StoredField under the same name, for example new StringField("id", "doc-1", Store.NO) plus new StoredField("id", "doc-1"), or new IntField("count", 5, Store.NO) plus new StoredField("count", 5). Checked on stock main: the term and exact queries match, get("id") = "doc-1" and numericValue() = 5.

Trade-offs

Needs a change in every application that builds such documents. For the StoredFieldDataInput case, pass the bytes as StoredField(String, BytesRef) instead; that constructor already agrees with IndexWriter.

Downstream note

Downstream note: a percolator or Monitor matcher that reads these stored values back from a MemoryIndex gets null, so application code that treats a missing value as "no restriction" would fail open on every such document; no such code is known.

How it was found

Bounded-exhaustive translation validation on the stock jar: every stored-value-producing field class (16 kinds, one or two values each) went into a one-field document indexed by MemoryIndex and by IndexWriter, and the stored fields were compared. It found 14 mismatches (7 kinds, one and two values each); the finder reports none with the fix sketch. Reading storeValues gave the cause.

The method in full161 words

Method: bounded-exhaustive translation validation on the stock jar (semi-formal tier), in a hunt unit that checked MemoryIndex against IndexWriter as the reference.

  • The harness's stored-fields scope built each stored-value-producing field class (16 kinds x 1-2 values: 32 cases) into a single-field document, indexed it once with MemoryIndex and once in a one-document IndexWriter index, and compared what a StoredFieldVisitor saw.
  • On the stock main jar it reported 14 STORED mismatches: StringField(String), KeywordField(String), IntField, LongField, FloatField, DoubleField and StoredField(StoredFieldDataInput), each with one and two values; the finder reports 0 with a storedValue()-based storeValues (no log of that run is kept; the diagram probe with the fix shadowed agrees with IndexWriter on all 13 cases).
  • The finder traced the mismatches to MemoryIndex.storeValues, wrote a public-API JUnit with the IndexWriter value asserted first, and ran it on main and on releases back to 9.5.0. Independent verifiers then reproduced it with their own tests (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26) in 78 queries over GitHub, the mailing lists, JIRA, Elasticsearch, OpenSearch, Solr, Stack Overflow and the web, plus the changed files of all 411 open PRs. Closest: a #12054 review comment on the same guessing in IndexWriter's path, which #12116 fixed.

Searches3 searches

Verdict new. The hazard of guessing from binaryValue() was known for IndexWriter's stored-fields path (the #12054 review, fixed by #12116), but MemoryIndex's own copy is different code with a different fix and is mentioned nowhere. A re-check on 2026-10-27 was blocked by GitHub's search rate limit; the fact check's read-only GitHub API searches on the same day found no new report. Keyword search cannot prove absence.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; downstream repositories; the web2026-10-26the hunt's prior-report tool: 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA and 6 web queries (520 distinct hits; every high or medium hit created after 2022-12-06 read in full)no report; the top hits are the introducing changes #11999, #12116, #12139, #14213 and the open design issue #12142
GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr, all of GitHub), lists.apache.org, Stack Overflow2026-10-2615 manual GitHub searches, 4 GitHub API checks, 3 mailing-list and 4 Stack Overflow queries, 1 web queryno report; the #12054 review comment (same guessing, IndexWriter's path); #12109 (a different MemoryIndex/IndexingChain divergence, fixed as a bug); Stack Overflow 78129318 (stores a LongField in a MemoryIndex, never reads it back)
open apache/lucene PRs2026-10-26changed files of all 411 open PRs8 touch lucene/memory; none touches storeValues, storedValue or stored fields

History

Latent since #11999 wrote storeValues (9.5.0, correct then); wrong since #12116 added storedValue() in 9.6.0 without migrating MemoryIndex, and #12139 gave StringField its binaryValue() override in the same release. Affects 9.6.0 to 10.5.2 and main; the DataInput case since 10.2.0. Not fixed upstream.

Versions tested10 rows
  • Latent code: #11999 "Add support for stored fields to MemoryIndex" (@romseygeek; 66127f6e696, 2022-12-08; 9.5.0) wrote storeValues; all 19 lines (867-885) still blame to it. It was correct then: storedValue() did not exist and no stock stored field had a binary form for a non-binary value.
  • Introduced in 9.6.0: #12116 "Improve document API for stored fields" (@jpountz; 96136b42821, 2023-02-06; 9.x 84dcaa850b1) added storedValue(), moved IndexWriter to it and gave the numeric fields Store constructors, but did not change MemoryIndex.java. In the same cycle #12054 (ab074d54839) added KeywordField with a term binaryValue(), and #12139 (cce33b07e4f, 2023-02-21; 9.x 668246439dd) gave StringField one, the regression for StringField(String); #12139 also made MemoryIndex.addField index that binaryValue() as the term, and left storeValues unchanged.
  • 10.2.0: #14213 (f12e44b6515, 2025-02-19) added StoredFieldDataInput, visible only through storedValue(), so MemoryIndex drops it.
  • The releases between the executed ones have a byte-identical storeValues and the same field overrides (source identity per tag). A read-only check on 2026-10-27 found upstream main and branch_10x with the same storeValues.
Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's JUnit 7 of 8 fail through gradle and the classpath runner, assertions on and off; control passes
main ea57360912ed affectedthe hunt's repro 2 of 2 and the independent suite 8 of 11 fail, -ea and -da; 3 controls pass
10.5.2 affectedthis doc's JUnit 7 of 8 fail; the independent suite 8 of 11
10.2.0 DataInput case arrivesthe DataInput case fails (10.1.0: the API does not exist)
9.12.3, 10.0.0, 10.1.0 affectedthe independent suite 7 of 10 fail (no DataInput API); 9.12.3 also -da (the hunt's repro)
9.6.0, 9.7.0 first affected9.6.0: 7 of 10 fail; StringField-only variant fails on 9.6.0 and 9.7.0
9.6.0 jars, StringField.java shadowed commit levelthe file from just before the #12139 backport passes 5 of 5; the #12139 file fails 2 of 5
9.5.0 last cleanStringField(String) and the controls pass 5 of 5 (KeywordField and stored numeric fields do not exist yet)
9.4.2 no stored fieldsMemoryIndex returns no stored fields at all
fix A (scratch worktree at a9779ef3c193) not affectedthe repro 8 of 8, -ea and -da; lucene/memory 59 and lucene/monitor 181 tests pass (fix B: the same suites pass)

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing; reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication confirmed it after re-running the independent suite, and this doc re-ran everything on a9779ef3c193.

Checks and verdicts462 words

Every executing check wrote or re-ran its own JUnit on stock main; every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateTranslation-validation harness (14 mismatches; the finder reports 0 with the fix sketch) and a public-API JUnit: 2 of 2 fail on main (-ea, -da) and 10.5.2, 1 of 1 on 9.12.3, 9.7.0 and 9.6.0; 9.5.0 passes; the fix passes it and 51 upstream memory tests.
cross-model refute review not real (low, procedural)An independent reviewer from another model family (OpenAI GPT-5.6) that could only read code: root cause agreed, inputs in contract, 9 of its 11 evidence items support real; it needed an executed repro of its own. A separate run re-ran the repro: 2 of 2 fail.
independent reproducer realOwn 11-case suite (8 defect cases, 3 controls, IndexWriter value asserted first): 8 fail on main with -ea and -da; 11 of 11 pass with the fix; 10.5.2 8 of 11, 9.12.3 and 9.6.0 7 of 10, the DataInput case fails on 10.2.0, 9.5.0 passes.
cross-model intended-behaviour review real (medium)Same reviewer family, works-as-designed lens: the class-javadoc defence is moderate and does not hold, since #11999 superseded it and #12116 was written to end this guessing. Separate re-run: 2 of 2 fail.
intended-behaviour devil's advocate real (high; medium-high for the DataInput case)No javadoc, CHANGES, MIGRATE entry or test documents a reduced contract; re-ran main -ea/-da and 10.5.2 (fail), 9.6.0 fail, 9.5.0 pass.
adjudication confirmed (high)Re-ran the independent reproduction's suite on main: 11 run, 8 failed with -ea and with -da, controls pass.
Follow-up and main refresh reproduces; keepHistory runs from 9.4.2 to main, a security probe (matching unchanged in 2,600 evaluations), prior-report search; the refresh to a9779ef3c193 gives identical failures.
this doc's runs reproduces; fix validatedA compact gradle JUnit fails 7 of 8 on stock main with assertions on and off, and on the 10.5.2 jars; fixes A and B were applied in a fresh scratch worktree of a9779ef3c193: the repro, all lucene/memory and lucene/monitor tests, format and lint pass; the workaround was checked on stock main.
Independent fact and diagram checks errors fixedA fact check re-ran the gradle repro (stock 7 of 8 fail, fix A 8 of 8, assertions on and off), all lucene/memory and lucene/monitor tests with fix A, the classpath runs on main and 10.5.2 and the workaround; a cross-model fact check (OpenAI GPT-5.6, read-only) re-read every citation; a diagram check compared every frame with the code. Their errors (a line count, a missing #12139 fact, the scope of "the other StoredField constructors", fix B called an exact mirror of IndexWriter) and the overclaims they listed were fixed in this doc and the brief.

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

Finding record

LFV-330, snapshot of 2026-11-06

Registry title
MemoryIndex stores binaryValue() instead of IndexableField.storedValue(), so stored StringField/KeywordField values built from a String and stored IntField/LongField/FloatField/DoubleField values come back as binary bytes (Document.get and numericValue() return null), and a StoredField built from a StoredFieldDataInput is dropped (released since 9.6.0)
Area
memory / MemoryIndex stored fields
Priority
Medium, rank 237: Since 9.6.0, MemoryIndex deterministically and silently returns stored StringField and numeric values with the wrong type, but only for the niche pattern of reading stored fields from it, so it heads the narrow silent rows after LFV-237.
Impact class
silent wrong results
Impact note
Applications that put a stored StringField or KeywordField built from a String, or an IntField/LongField/FloatField/DoubleField with Store.YES, into a MemoryIndex (addField, fromDocument, or a single-document Monitor batch) and read stored fields back from its searcher, for example to display or highlight a percolated or monitored document, get the value with the wrong type on every document and no error (Document.get and numericValue() return null, although the bytes still decode to the original value), and lose a StoredField(StoredFieldDataInput) value completely (@lucene.experimental, 10.2.0 and later); default settings and stock field classes, but reading stored fields back from a MemoryIndex is an uncommon pattern with no in-tree consumer; every release from 9.6.0 through 10.5.2 and main; ordinary scale.
Summary
You build a MemoryIndex from a document with StringField("id", "doc-1", Store.YES), a KeywordField(String, Store.YES) or an IntField("count", 5, Store.YES) and read it back through storedFields().document(0), expecting the same stored values IndexWriter returns, since MemoryIndex has supported stored fields since 9.5.0 (#11999). Instead MemoryIndex stores the field's binaryValue() rather than its storedValue(): the String comes back as its UTF-8 bytes, so Document.get("id") returns null, and the numbers come back as their sortable point bytes (IntField 5 as [80 00 00 05]), so numericValue() returns null. A StoredField built from a StoredFieldDataInput is not stored at all; the BytesRef constructors and plain StoredField values are unaffected.
Introduced
#12116 (96136b42821 on main, 84dcaa850b1 on branch_9x, 'Improve document API for stored fields') [read: git log -L/-S, git blame and the PR threads; exec: release runs 9.5.0 pass and 9.6.0 fail, plus a single-class StringField shadow on the 9.6.0 jars]: it added IndexableField#storedValue(), moved IndexWriter to it and gave IntField/LongField/FloatField/DoubleField Store constructors, but did not migrate MemoryIndex.storeValues, which #11999 (66127f6e696, 2022-12-08, first released in 9.5.0) had written and which was correct then (storedValue() did not exist; all 19 lines, 867-885, blame to it, unchanged since). In the same 9.6.0 cycle #12054 (ab074d54839) added KeywordField with a term binaryValue() and #12139 (cce33b07e4f) gave StringField one, which caused the headline StringField(String) regression (#12139 also made MemoryIndex.addField index that binaryValue() as the term, but left storeValues unchanged) [exec: on the 9.6.0 jars StringField.java from ecaee57aceb (before the #12139 backport) passes 5/5 and from 668246439dd (#12139) fails 2/5]; #14213 (f12e44b6515, 10.2.0) added StoredFieldDataInput, visible only through storedValue(), so MemoryIndex drops it [exec: 10.1.0 API absent, 10.2.0 fail] (2023-02-06 (#12116); 2023-02-07 (#12054) and 2023-02-21 (#12139) in the same 9.6.0 cycle; 2025-02-19 (#14213, the StoredFieldDataInput case))
First affected version
9.6.0 [exec] (9.5.0 is the last unaffected release); the StoredFieldDataInput case 10.2.0 [exec], the first release with that API
Affected versions
9.6.0 through 9.12.3, 10.0.0 through 10.5.2 and main ea57360912ed: executed on 9.6.0, 9.7.0 (StringField case only), 9.12.3, 10.0.0, 10.1.0, 10.2.0, 10.5.2 and main with -ea, and also with -da on 9.12.3, 10.5.2 and main [exec]; the releases in between have a byte-identical storeValues, no storedValue() call in MemoryIndex and the same binaryValue() overrides [read: source identity per tag]; the StoredFieldDataInput case 10.2.0 and later only; 9.5.0 not affected (StringField(String) and the controls pass) [exec]; before 9.5.0 MemoryIndex returned no stored fields at all (9.4.2) [exec]; upstream main e1c4184cb983 and branch_10x unchanged on 2026-10-26 [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
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record, 78 read-only queries: a scripted prior-report search with 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA-archive and 6 web queries, 520 distinct hits; 15 manual GitHub searches including Elasticsearch, OpenSearch and Solr, 4 GitHub API checks, 3 mailing-list, 4 Stack Overflow and 1 web query (throttled); the changed-file lists of all 411 open PRs); related prior items: #12054 (a review comment on the same binaryValue-first guessing in the IndexWriter stored-fields path, which #12116 then fixed there; MemoryIndex not mentioned), #12116 (added storedValue(), did not migrate MemoryIndex), #12139 (StringField binaryValue() override), #11999 (wrote storeValues), #14213 (StoredFieldDataInput), #12142 (open design discussion, no MemoryIndex), #12109 (a different MemoryIndex/IndexingChain divergence fixed as a bug); no open PR touches storeValues; upstream main e1c4184cb983 still has the same storeValues
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): this doc's JUnit through gradle in a fresh scratch worktree (only the repro and, for the fixed runs, the patch added), stock and with fix A, assertions on and off; fix A and fix B with all lucene/memory and lucene/monitor tests, format and lint (2026-10-27); the same JUnit through the hunt's classpath runner on main (assertions on and off, and with fix A shadowed) and on the 10.5.2 release jars. Hunt and verifier runs on main ea57360912ed and on 9.4.2, 9.5.0, 9.6.0, 9.7.0, 9.12.3, 10.0.0, 10.1.0, 10.2.0 and 10.5.2; the refresh to a9779ef3c193 gave identical failures. An independent fact check re-ran the gradle repro (stock and fix A, assertions on and off), the suites with fix A and the classpath runs on 2026-10-27.

Lucene and upstream sources

Finding record
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:867-885 (storeValues), :673-674 (stored gate in addField), :2242-2273 (stored-fields visitor), :133-135 (class javadoc: fulltexts 'not stored but rather thrown away'), :531-575 (fromDocument), :620 (addField)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1436-1450 (IndexWriter stores field.storedValue(), throws on null)
  • lucene/core/src/java/org/apache/lucene/index/IndexableField.java:75-78 (storedValue javadoc); lucene/core/src/java/org/apache/lucene/document/Field.java:597-598 (Store.YES: 'stored in its original form')
  • lucene/core/src/java/org/apache/lucene/document/StringField.java:61, 95-96, 118-119; KeywordField.java:98, 107-108; IntField.java:83-88 (binaryValue() returns the term or the sortable point encoding; storedValue() the String or number)
  • lucene/core/src/java/org/apache/lucene/index/StoredFieldDataInput.java:27 (@lucene.experimental)
  • lucene/CHANGES.txt:3752 (GITHUB#11999 'MemoryIndex now supports stored fields'), :3553 (GITHUB#12116), :3568 (GITHUB#12054), :3612 (GITHUB#12139), :1904 (GITHUB#14213)
  • lucene/memory/src/test/org/apache/lucene/index/memory/TestMemoryIndex.java:719 (testStoredFields: typed round trips, StoredField only)
The finding in 30 seconds
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:531-575 (fromDocument), :620 (addField), :673-675 (stored gate), :867-885 (storeValues), :2242-2273 (stored-fields visitor)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1491-1505 (IndexWriter stores field.storedValue(), throws on null)
  • lucene/core/src/java/org/apache/lucene/document/StringField.java:59-66, 95-96, 118-119; KeywordField.java:96-103, 107-108, 135-136; IntField.java:73-80, 84-88, 91-92; LongField.java:87-90; FloatField.java:82-86; DoubleField.java:82-86
  • lucene/core/src/java/org/apache/lucene/document/Field.java:264-270, 419-434, 607-624; StoredField.java:116, 147-157 (the CharSequence constructor)
  • KeywordField row: the repro asserts get("tag") = null; the bytes are new BytesRef("red") (KeywordField.java:98), the UTF-8 of "red"; the independent reproduction executed KeywordField("abc") -> binary[97,98,99] (verify/judge/v1-main-ea)
Who is affected
  • lucene/monitor/src/java/org/apache/lucene/monitor/DocumentBatch.java:44-46, 55-63, 103-114
  • git diff --stat ea57360912ed a9779ef3c193 -- lucene/memory lucene/monitor (empty)
Why it looks unintended
  • lucene/CHANGES.txt:3768 (GITHUB#11999), :3569-3571 (GITHUB#12116), :3584 (GITHUB#12054), :3628 (GITHUB#12139), :1920 (GITHUB#14213)
  • lucene/core/src/java/org/apache/lucene/index/IndexableField.java:74-78; document/Field.java:596-599; index/StoredFieldDataInput.java:27
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:133-135 (class javadoc)
  • lucene/memory/src/test/org/apache/lucene/index/memory/TestMemoryIndex.java:719-766 (testStoredFields)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1495-1502 (MAX_STORED_STRING_LENGTH); codecs/lucene90/compressing/Lucene90CompressingStoredFieldsWriter.java:303-319 (bytes copied at addDocument)

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

Repro files

About the diagrams

The values in the alt text are the executed repro values on main a9779ef3c193.

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.