MemoryIndex keeps one entry per field name. Its StoredFields.document loop calls needsField once per entry, before it checks whether the entry has stored values, and on YES hands over all of them (MemoryIndex.java:2247-2270). The checked index readers call it once per stored value, and only for stored values.
- On an index-backed reader,
StoredFields.document(doc, visitor)walks the stored values of one document. Before each value it asksvisitor.needsField(fieldInfo): YES delivers the value, NO skips it, STOP ends the walk. No javadoc states this call count; it is how those readers are written. - An index written by
IndexWriterkeeps every added value separately. A field added twice with a stored value is asked about twice; a field added withStore.NOis never asked about. MemoryIndexis a one-document, in-memory index that documents itself as a substitute for a RAM-residentDirectory. Since 9.5.0 it also returns stored fields.
needsField once per stored value of tag; MemoryIndex asks once per field name, including the unstored body, and then hands over both values. Select the still to open it at full size. Generated with AI, checked against the code.- Build a MemoryIndex from one document:
body= "x" (TextField,Store.NO) andtagstored twice, "t1" and "t2". Read it withstoredFields().document(0, visitor)and a visitor of your own. - You expect what an index written by
IndexWritergives for the same document:needsFieldonce per stored value and never for a field that stores nothing, so the visitor is asked [tag, tag]. - Instead MemoryIndex asks [body, tag]: once per field name, including
body, and then hands over both values oftagon that single YES. - So a "first value only" visitor gets [t1, t2] instead of [t1];
LazyDocumentused as in its own test returns t1 but not t2 (assertions off), and a lazy field made forbodythrowsArrayIndexOutOfBoundsExceptionwhen read. document(0)and field-set loads stay correct: they accept every value by name. It has been this way since stored fields came to MemoryIndex in 9.5.0.
Applications that read stored fields back from a MemoryIndex through their own StoredFieldVisitor that decides per value or assumes it is offered only stored fields, or through LazyDocument. Default settings, every release since 9.5.0. document(int), field-set loads and visitors that decide by name stay correct; nothing is written or corrupted.
Mechanism, terms and numbers577 words
MemoryIndex has returned stored fields since 9.5.0. Its StoredFields.document(int, StoredFieldVisitor) walks the index's fields by name and calls visitor.needsField once per name, before it checks whether that field has any stored value, then delivers all stored values of the name on that one YES. An index written by IndexWriter asks once per stored value and only for stored values. So on a MemoryIndex the visitor is offered fields that store nothing (indexed-only, points-only or doc-values-only), and a field stored N times gets one decision instead of N: LazyDocument throws ArrayIndexOutOfBoundsException for the unstored field or silently loses later values, and a visitor's per-value NO or STOP is ignored. document(int) and name-based visitors are unaffected; nothing is written or corrupted.
- One entry per field name, whatever the field is.
fieldsis aSortedMap<String, Info>(MemoryIndex.java:418).addField(:620) callsgetInfo(:622), which creates anInfofor every new name (:739-744), for indexed, points, doc-values and vector fields alike. Stored values are kept only when the field type is stored (:673-675);storeValuesadds one entry per stored value (:867-885). Sobody(Store.NO) has anInfowithstoredValues == null, andtaghas oneInfowith two values. - The read loop asks once per name, before looking at the values.
for (Info info : fields.values())(:2247) callsvisitor.needsField(info.fieldInfo)(:2248), returns on STOP (:2249-2251), skips on NO (:2252-2254), and only then checksinfo.storedValues != null(:2255) and delivers every value of the name (:2256-2270). - What an index does. The default codec's reader loops over the document's stored values,
for (int fieldIDX = 0; fieldIDX < doc.numStoredFields; fieldIDX++)(Lucene90CompressingStoredFieldsReader.java:694), and callsneedsFieldfor each one (:706). The hunt's exhaustive check found the same behaviour in all 15 index-backed reader configurations it ran other thanParallelLeafReader(default codec, SimpleText, wrappers, composite readers, merge paths).ParallelLeafReader, run separately, also asks once per stored value; its only protocol deviation is an unrelated STOP leak. - Why LazyDocument breaks. Each
getField(fieldInfo)adds one lazy value for that field (LazyDocument.java:68-88). On first read it loads the document by name and assignsf.realValue = realValues[i]per lazy value (:107-122). Forbody,realValuesis empty, so index 0 throwsArrayIndexOutOfBoundsException(:119). Fortagonly one lazy value was made, so t2 is never handed out; with assertions on, the assertion at:113-114fires first (it runs in the wrong direction, LFV-697). - Not claimed: MemoryIndex delivers values grouped by name in sorted order, an index in the order added. No javadoc promises an order, and the fixes keep it.
| visitor | index | MemoryIndex |
|---|---|---|
record needsField, answer NO; body (not stored) + tag = t1, t2 | [tag, tag] | [body, tag] |
YES the first time a name is offered, NO after; tag = t1, t2 | [tag=t1] | [tag=t1, tag=t2] |
YES for the first value, then STOP; tag = t1, t2 | [tag=t1] | [tag=t1, tag=t2] |
count needsField calls; tag = t1, t2 | 2 | 1 |
count needsField calls; body + tag = t1 | 1 | 2 |
LazyDocument, one lazy field per offered field; tag = t1, t2 | [tag=t1, tag=t2] | [tag=t1] |
same; body + tag = t1 | [tag=t1] | [body -> ArrayIndexOutOfBoundsException, tag=t1] |
document(0) | [tag=t1, tag=t2] | [tag=t1, tag=t2] |
With the recommended fix every row agrees.
Exposure and likelihood295 words
- Who is hit. Code that calls
StoredFields.document(int, StoredFieldVisitor)on a MemoryIndex with a visitor that decides per value ("first value", "up to N values", STOP after a value, counting calls) or expects only stored fields, and users ofLazyDocument(lucene/misc) over a MemoryIndex. Any document with an unstored field or a field stored more than once triggers it. - What they get. An
ArrayIndexOutOfBoundsExceptionfrom a lazy field, silently missing values (later values of a field with LazyDocument, assertions off), or values a visitor declined. Silent except for theArrayIndexOutOfBoundsExceptionand, with assertions on, theAssertionErrorLazyDocument raises instead of losing t2 (LFV-697). The registry's impact class is silent wrong results; for the per-value leg, which rests on reader behaviour rather than a written contract, the fairer framing is a reader-protocol inconsistency that can give per-value visitors silently wrong results. - Who is not hit.
document(int),document(int, Set<String>)/DocumentStoredFieldVisitorand other visitors that decide by name and accept every value. By code reading, the adjudication and the intended-behaviour check found that the highlighters' stored-field visitors andStoredFieldsWriter.MergeVisitoralso decide by name, so by that reading their output is not affected (code reading only: the highlighter and monitor modules were not run, with or without the fix); UnifiedHighlighter'sLimitedStoredFieldVisitordecides per value but enforces the same length budget instringField, so only its skip shortcut is lost. In the Lucene tree, the highlighters and the monitor module use MemoryIndex for matching and offsets, not to read its stored fields back. - Scale and likelihood. One in-memory document per MemoryIndex, so the effect is per read and per document. Reading MemoryIndex stored fields through a custom per-value visitor is niche, and the recorded searches found no report of it since 9.5.0: likelihood rare, priority low.
One JUnit test in lucene/memory, public API only: one document and a visitor that records its needsField calls. It fails on main and on 10.6.0, with assertions on and off.
/*
* 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 java.util.ArrayList;
import java.util.List;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.FieldInfo;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.StoredFieldVisitor;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestMemoryIndexStoredFieldVisitorCalls extends LuceneTestCase {
public void testNeedsFieldOncePerStoredValue() throws Exception {
Document doc = new Document();
doc.add(new TextField("body", "x", Field.Store.NO));
doc.add(new StoredField("tag", "t1"));
doc.add(new StoredField("tag", "t2"));
IndexReader reader =
MemoryIndex.fromDocument(doc, new StandardAnalyzer()).createSearcher().getIndexReader();
List<String> asked = new ArrayList<>();
reader
.storedFields()
.document(
0,
new StoredFieldVisitor() {
@Override
public Status needsField(FieldInfo fieldInfo) {
asked.add(fieldInfo.name);
return Status.NO;
}
});
// expected (as for an IndexWriter index): one call per stored value of tag, none for the
// unstored body; actual: [body, tag]
assertEquals(List.of("tag", "tag"), asked);
}
}
cp $REPRO/TestMemoryIndexStoredFieldVisitorCalls.java $LUCENE/lucene/memory/src/test/org/apache/lucene/index/memory/
cd $LUCENE && ./gradlew :lucene:memory:test --tests org.apache.lucene.index.memory.TestMemoryIndexStoredFieldVisitorCalls -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
- Expected
[tag, tag]: oneneedsFieldcall per stored value oftag, none for the unstoredbody, as an IndexWriter index gives.- Observed
AssertionError: expected:<[tag, tag]> but was:<[body, tag]>, with assertions on and off.
More files, environment, full output185 words
The test uses only public classes and methods (MemoryIndex.fromDocument, createSearcher, StoredFields.document(int, StoredFieldVisitor)) and sits in MemoryIndex's test package only to match the upstream test location. It uses no randomness: assertions on and off and every seed tried (DEADBEEF, 5EED7451, 1A2B3C4D) give the same result. The expected list [tag, tag] is what an IndexWriter index returns for the same document (each of the finder's two tests asserts that control first; on 645d4fe14a20 the control assertion passes before the MemoryIndex assertion of the same test fails). One assertion covers both legs: body is offered although it stores nothing, and tag is asked once for two values. The suggested regression test is this same test.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars (4ae946e5a9b, the commit in the jars' manifest and of the release tag releases/lucene/10.6.0) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
> Task :lucene:memory:test FAILED
TestMemoryIndexStoredFieldVisitorCalls > testNeedsFieldOncePerStoredValue FAILED
java.lang.AssertionError: expected:<[tag, tag]> but was:<[body, tag]>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:BC27CAEB93919950]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:120)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
at org.apache.lucene.index.memory.TestMemoryIndexStoredFieldVisitorCalls.testNeedsFieldOncePerStoredValue(TestMemoryIndexStoredFieldVisitorCalls.java:54)StoredFields.document"visits the fields of a stored document", hard to square with offering aStore.NOfield; no checked index reader offers one.- Checked index readers ask once per stored value;
LazyDocument.getField"must be called once for each value", as its test does. - MemoryIndex calls itself a RAM-resident Directory substitute; the loop came with #11999, whose review never discussed call counts.
The full argument321 words
- Reader contract.
StoredFields.java:73-77: "Expert: visits the fields of a stored document, for custom processing/loading of each field." - Visitor contract.
StoredFieldVisitor.java:80-84:needsFieldis the "Hook before processing a field. Before a field is processed, this method is invoked so that subclasses can return a Status representing whether they need that particular field or not, or to stop processing entirely."Status.NO: "don't visit this field, but continue processing fields for this document" (:90). Each added value is its own field instance (tagis twoStoredFieldobjects), and the index-backed readers that were checked treat it that way (the default codec, SimpleText and the 15 configurations of the exhaustive check other thanParallelLeafReader, which also asks per value and differs only by an unrelated STOP leak). - LazyDocument.
LazyDocument.java:61-63:getField"must be called once for each value of the field name specified in sequence that the values exist".TestLazyDocumentcalls it fromneedsField(TestLazyDocument.java:203-215). On SOLR-4589 (2013), where that sentence comes from, a committer (hossman) wrote thatgetField"assumes it will be called exactly once for each StorableField that exists for that field name, in order". - Design intent. LUCENE-3309 (#4382, 2011), which introduced the visitor: the reader "invokes this API once per field in the document being visited", and the name StoredFieldVisitor was asked for because "FieldVisitor sounds really general and like it visits all fields, not just stored ones".
- MemoryIndex's role. "a replacement/substitute for RAM-resident Directory implementations" (
MemoryIndex.java:106). Earlier divergences of MemoryIndex from an index reader were fixed as bugs (#7462 / LUCENE-6402, #12109). - History. The loop came with #11999 and is unchanged since. Its review discussed whether MemoryIndex should have stored fields and how to keep the values, never the call count or unstored fields;
TestMemoryIndexreads stored fields only throughdocument(0)(TestMemoryIndex.java:749). No inspected test, javadoc, release note or #11999 review comment says the per-name behaviour is intended.
The devil's-advocate check176 words
Strongest case (the refute review, rated moderate): StoredFieldVisitor is expert and @lucene.experimental (StoredFieldVisitor.java:35), and no javadoc states how often needsField is called or that fields without stored values are left out. MemoryIndex keeps one entry per name, so one decision per name is a defensible design, and LazyDocument's once-per-value rule is a precondition on its caller.
Why it fails: it does not explain offering a field that stores nothing, which is hard to reconcile with "visits the fields of a stored document" (though no javadoc excludes such fields in so many words), and that leg alone makes LazyDocument throw. For the per-value leg, every index-backed reader that was checked calls per value, the visitor was designed to be invoked once per field in the document (LUCENE-3309), LazyDocument's own test relies on it, and MemoryIndex presents itself as a Directory substitute. The call count is not written down; hence the optional javadoc sentence with fix A, and the smaller fix B. The cross-model review and the intended-behaviour check judged the argument insufficient; the adjudication confirmed.
Recommended Skip fields without stored values and ask once per stored value
In the read loop, skip a field that has no stored values and call needsField once per stored value instead of once per name (one method in MemoryIndex.java). The repro and the whole lucene/memory test module (52 tests) pass, with assertions on and off.
All options and trade-offs3 options · 602 words
Three options. A was built and run through gradle in a scratch worktree of 645d4fe14a20 and through the standalone probe; B was built and run through the standalone runner; C is listed for completeness.
Skip fields without stored values and ask once per stored value (validated by the checks) (recommended)
Move the needsField call inside the loop over info.storedValues, and skip an Info whose storedValues is null (MemoryIndex.java, one method, +23/-21, mostly re-indentation). Value order is unchanged, so A aligns the calls per value, not the whole visit trace (values still come grouped by name in sorted order). The skip test is storedValues == null, which holds exactly when no stored value was added (storeValues, MemoryIndex.java:867-885); checked with ordinary Lucene field classes only. Optional companion, not written or built: one javadoc sentence on StoredFieldVisitor#needsField saying it is called once for each stored value, only for fields with stored values.
for (Info info : fields.values()) {
if (info.storedValues == null) {
continue; // nothing stored for this field: do not offer it to the visitor
}
for (Object value : info.storedValues) {
// one decision per stored value, like the index-backed readers
StoredFieldVisitor.Status status = visitor.needsField(info.fieldInfo);
if (status == StoredFieldVisitor.Status.STOP) {
return;
}
if (status == StoredFieldVisitor.Status.NO) {
continue;
}
// unchanged: binaryField / doubleField / floatField / longField / intField / stringField
}
}Trade-offs
- Matches what the checked index-backed readers do;
document(int)and name-based visitors return the same document. - Cost: one
needsFieldcall per stored value instead of per name, on a one-document index. No benchmark was run. - Changes the per-value leg, whose call count no javadoc states; a maintainer may prefer B plus a javadoc decision. Compatibility: a visitor written against MemoryIndex's current behaviour that keeps state and relies on one decision per name would now be asked once per value; B avoids that change.
What the checks validated
- gradle, stock + test: fails (
expected:<[tag, tag]> but was:<[body, tag]>); + fix: passes. - gradle, + fix: the whole
lucene/memorymodule (TestMemoryIndex30,TestMemoryIndexAgainstDirectory16,TestSlicedIntBlockPool5, the repro 1: 52 tests, 0 failures), with assertions on and with assertions off. - gradle, + fix and the test:
:lucene:memory:check -x testpasses (google-java-format, ecjLint, forbiddenApis). - standalone probe with the fix: all 8 index-versus-MemoryIndex comparisons agree (LazyDocument, first-value and STOP visitors, call counts,
document(0)). - again, in a second, fresh scratch worktree: stock + test fails with assertions off; + fix: the whole
lucene/memorymodule, the repro included, passes with assertions off and on (52 tests, 0 failures); the standalone runner with the fix passes the repro with-eaand-daon another seed. - Not run: the full Lucene suite, nightly tests, the highlighter and monitor modules.
Add if (info.storedValues == null) continue; before the needsField call (+3 lines). Keeps one decision per name.
Trade-offs
Fixes the leg on the explicit contract and LazyDocument's exception with the smallest change. The multi-valued leg stays: LazyDocument still loses t2, first-value and STOP visitors still get both values. The repro then fails with expected:<[tag, tag]> but was:<[tag]>, so the regression test would need tag stored once.
What the checks validated
Standalone runner, main 645d4fe14a20, assertions off: the repro fails with [tag]; in the probe the unstored-field rows agree and the multi-valued rows still differ.
Say in the MemoryIndex javadoc that needsField is asked once per field name, for every field.
Trade-offs
Hard to reconcile with "visits the fields of a stored document", and leaves LazyDocument's exception in place.
A visitor's per-value NO or STOP is ignored on a MemoryIndex, which matters only to an application that relies on it to withhold later values of a field.
A bounded-exhaustive check of the stored-field visitor protocol on the stock jars: 6,174 small documents, each written once with IndexWriter and once with MemoryIndex, read with every YES/NO/STOP script. MemoryIndex differed in 80,892 of 89,802 visits, and a "one call per field name" model matched all of them. A follow-up probe found the unstored-field leg.
The method in full162 words
Method: semi-formal (bounded-exhaustive translation validation), a hunt unit (StoredFieldVisitor protocol across implementations), 2026-10-27, main a9779ef3c193. The harness built single documents of 1-3 stored values over the names a, b, c and 6 stored types (6,174 documents), wrote each once with IndexWriter and once with MemoryIndex, and read each with every prefix-distinct YES/NO/STOP script, comparing the visit traces with the protocol all index-backed readers follow.
- 15 index-backed configurations (default codec, SimpleText, wrappers, composite readers, merge paths): 0 deviations.
ParallelLeafReader, run separately, deviates only by an unrelated STOP leak. - MemoryIndex: 80,892 of 89,802 visits differ; 7,020 of them differ only in the order of values across field names, which is not claimed.
- The alternative model "one
needsFieldper field name, names in sorted order, all values of the name on YES" matches all 89,802 visits, which pins the mechanism. - A follow-up probe found that an indexed, not stored field is offered too; the finding was then reduced to a public-API JUnit.
No prior report found: GitHub issues, PRs and commits (Lucene and three downstream projects), the mailing lists, the JIRA archive and the web, searched 2026-10-27. The closest item is SOLR-4589 (2013), where LazyDocument's once-per-value rule was written down.
Searches4 searches
Context, not reports: #11999 introduced the loop (its review never discussed the call count, unstored fields or LazyDocument); SOLR-4589 states LazyDocument's precondition; #4382 (LUCENE-3309) is the visitor's design intent; #6222 (LUCENE-5158) and a 2016 java-user@ thread describe the per-value, insertion-order calls of index readers; #7462 (LUCENE-6402) and #12109 fixed other MemoryIndex divergences as bugs; open PR #16847 touches MemoryIndex.java, StoredFields.java and TestLazyDocument.java but not the loop. No relevant hit for MemoryIndex together with needsField, StoredFieldVisitor or LazyDocument: in the direct GitHub, list and JIRA searches for those terms, the only raw hits are the GitHub mirror mails of #11999 on issues@, commit mails on commits@ and one unrelated backport list, lucenenet#1293. A refresh on 2026-10-30 could not repeat the GitHub search (API rate limit twice; a third try returned empty lists even for a control query that #11999 matches, so it is not counted); the upstream refs fetched 2026-10-29 show no commit touching MemoryIndex.java on main after 645d4fe14a20.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits; Elasticsearch, OpenSearch, Solr; all of GitHub) | 2026-10-27 | three scripted prior-report searches (51 + 65 + 65 planned queries) and 56 manual searches; 27 issues and PRs fetched with all comments (7 read in full, the rest searched for the bug terms) | no report; only the introducing PR, context items and one unrelated backport list (lucenenet#1293) |
| Open apache/lucene PRs and fix-site history | 2026-10-27 | changed files of all 408 open PRs; git log -S on the loop; main, branch_10x and branch_10_6 | 7 open PRs touch MemoryIndex.java, none the loop; the loop was added once, by #11999 |
| Mailing lists, JIRA archive, web | 2026-10-27 | 40 list searches (dev@, java-user@, issues@, commits@), 9 JIRA searches (LUCENE, SOLR), 5 web searches | no report (issues@ hits are #11999's mirror mails, commits@ hits are commit mails); SOLR-4589 read in full |
| Refresh | 2026-10-30 | upstream refs (fetched 2026-10-29): commits touching MemoryIndex.java on main after 645d4fe14a20; the loop on branch_10_6 | no new commit; same loop on branch_10_6; GitHub issue search not repeated (rate-limited twice; a third try failed its control query) |
Introduced by #11999 (66127f6e696, merged 2022-12-08), which added stored fields to MemoryIndex; first release 9.5.0. Every release from 9.5.0 to 10.6.0 and main fail; before 9.5.0 MemoryIndex had no stored fields.
Versions tested6 rows
#11999 "Add support for stored fields to MemoryIndex" (romseygeek; approved by mkhludnev, jpountz and uschindler) is 66127f6e696 on main and 89cfaa37720b on branch_9x, both 2022-12-08. It replaced the no-op document() ("no-op: there are no stored fields") with the current loop, which was already in the PR's first commit. Plain git blame shows #11998 (47f8c1baa2e8, 2022-12-13), which only moved the unchanged loop into the new storedFields() class. 9.4.2 and older have no MemoryIndex stored fields; a 9.x-API probe offers nothing on 9.4.2. The loop is the same on upstream main 716d6f216f5 and branch_10_6 (source read, not run; 2026-10-29 refs).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the repro fails: standalone with assertions on and off, gradle with assertions on and off |
upstream main 716d6f216f5, branch_10_6 head | read | same loop; no commit touches MemoryIndex.java on main after 645d4fe14a20 (source inspection, not run) |
10.6.0 release jars (4ae946e5a9b) | affected | the repro fails, assertions on and off; the commit is the one named in the jars' manifest and of the release tag releases/lucene/10.6.0, whose document() loop is identical to 645d4fe14a20's |
| 9.5.0 to 10.5.2 (26 releases) | affected | the finder's test fails on every release with -ea, and with -da on 9.5.0, 9.12.3 and 10.5.2 |
| 9.4.2 and older | no feature | MemoryIndex had no stored fields; document() was a no-op |
with fix A (main 645d4fe14a20) | not affected | the repro and the lucene/memory module pass |
The finder, an independent reproducer, a devil's advocate, a cross-model review (OpenAI GPT-5.6) and an adjudication rate it real; a second cross-model review, unable to run code, voted not real on procedure only and argued (moderate) that no javadoc states the call count or excludes unstored fields. An audit kept it; this doc's runs reproduced it and validated the fix.
Checks and verdicts371 words
The cross-model reviews were read-only and could not execute code; separate runs re-ran the repro, and all other execution evidence comes from Claude-family AI tools.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Bounded-exhaustive protocol check: MemoryIndex differs in 80,892 of 89,802 visits, the per-name model matches all; the JUnit fails on main, 10.5.2, 9.12.3 and 9.5.0 and passes with the fix. |
| cross-model review, refute lens (OpenAI GPT-5.6, read-only) | not real on procedure only, medium | Could not run its own repro, so it voted not real on procedure only; agrees on the root cause and the legal inputs. On substance (moderate) it argued that the API is experimental and no javadoc states the call count or that unstored fields are excluded. Execution: A separate run, not the reviewer, re-ran the repro; it fails 2 of 2. |
| Independent reproducer | real | Own five-scenario test with IndexWriter controls: exactly the 5 MemoryIndex variants fail on main, 10.5.2 and 9.12.3; 10 of 10 pass with the fix. |
| Cross-model review, intended lens (OpenAI GPT-5.6, read-only) | real, medium | Works-as-designed case moderate but insufficient (reviewer's reasoning). Execution: a recheck probe run by the review step, not by the reviewer, shows a doc-values-only field is offered too, on main, 10.5.2 and 9.5.0. |
| Devil's advocate | real, medium | The per-name reading fits the wording only for the per-value leg; its probe separates the two legs. |
| adjudication | confirmed, medium | Re-ran the independent reproduction's suite, the finder's repro and an own probe (StringField Store.NO, IntPoint, three values): 8 of 13 fail with -ea and -da, every control passes, 13 of 13 pass with the fix. |
| credibility audit | keep | Fresh-seed re-run fails; an own first-value/STOP probe without LazyDocument differs and passes with the fix. |
| minimal repro step | verified | The one-method test fails on 645d4fe14a20 with -ea and -da and passes with the fix. |
| this doc's runs | reproduced, fix validated | Gradle and standalone on main, the 10.6.0 release jars, the legs probe on stock, fix A and fix B, the lucene/memory module with fix A; then re-validated in a second, fresh scratch worktree (stock fails with assertions off; with fix A the module passes with assertions off and on; another seed). |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 353 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,479 words
- Registry title
- MemoryIndex's StoredFields.document offers the visitor fields that have no stored value and asks needsField only once per field name instead of once per stored value, so LazyDocument fails with ArrayIndexOutOfBoundsException or silently loses values, and per-value NO/STOP decisions are ignored
- Area
- memory / MemoryIndex stored fields (StoredFieldVisitor protocol)
- Priority
- Low, rank 600: MemoryIndex's stored-field visitor protocol drops or misreports values only for custom per-value visitors or LazyDocument over a MemoryIndex, an expert-API pattern with a debatable contract.
- Impact class
- silent wrong results
- Impact note
- Hits applications that read the stored fields of a MemoryIndex (directly, or the one-document index of a percolator- or Monitor-style matcher) through StoredFields.document(int, StoredFieldVisitor) with their own visitor that decides per value or expects to be offered only stored fields, or through LazyDocument used the way its own test uses it (default MemoryIndex settings, any size, -ea and -da, every release since 9.5.0): a lazy field created for a name with no stored value throws ArrayIndexOutOfBoundsException when read, with -da the second and later values of a multi-valued field are silently missing, and a visitor that answers YES and then NO or STOP still receives every value of the field; document(int), DocumentStoredFieldVisitor field sets, the highlighters' visitors and StoredFieldsWriter.MergeVisitor decide by name and accept every value, so their output is unchanged, and nothing is written or corrupted.
- Summary
- You add a document with an indexed-only field body (TextField, Store.NO) and a stored field tag with two values t1, t2 to a MemoryIndex and read it through storedFields().document(0, visitor), with a visitor that records its needsField calls or with LazyDocument's lazy-loading pattern from TestLazyDocument, expecting what an IndexWriter index gives for the same document: one needsField(tag) per stored value, never a question about body, and the lazy values tag=t1, tag=t2. Instead MemoryIndex asks about body, which has no stored value, asks about tag only once and then delivers both values on that single YES, because its loop calls needsField once per field name before it checks whether the field has stored values. So LazyDocument's lazy body field throws ArrayIndexOutOfBoundsException when read and tag=t2 is silently missing (with -ea LazyDocument's own inverted assertion, LFV-697, throws instead), and a visitor that wants only the first value of tag gets both.
- Introduced
- 66127f6e69637b081cbc8a2bbbdfac6d0b5dd81f (#11999 'Add support for stored fields to MemoryIndex', by romseygeek, approved by mkhludnev, jpountz and uschindler; branch_9x commit 89cfaa37720b) [read: gh pr view 11999, git log and git blame -w -M -C in the history clone; plain blame shows 47f8c1baa2e8 (#11998, 2022-12-13), which only moved the unchanged loop into the new storedFields() class]: it replaced the no-op MemoryIndexReader.document(int, StoredFieldVisitor) with the current loop, which asks needsField once per field name before checking for stored values. The per-name loop was already in the PR's first commit 9ea631782934; the review discussed whether MemoryIndex should have stored fields and how to keep the values, not visitor call semantics. The bug came with the feature; there was no earlier correct implementation. (2022-12-08)
- First affected version
- 9.5.0 [exec] (the first release with MemoryIndex stored fields: CHANGES.txt 9.5.0 'GITHUB#11999: MemoryIndex now supports stored fields'); 9.4.2 and older are not affected because MemoryIndex had no stored fields (document() was a no-op [read at 9.0.0 and 9.4.2]; a 9.x-API probe on 9.4.2 shows nothing offered and no value returned [exec])
- Affected versions
- Every release from 9.5.0 through 10.6.0 (27 releases: 9.5.0 to 9.12.3 and 10.0.0 to 10.6.0) and main (a9779ef3c193, 645d4fe14a20); never fixed. [exec] the finder's JUnit fails with the same two messages on the 26 releases 9.5.0 to 10.5.2 with -ea and on main, and with -da on 9.5.0, 9.12.3, 10.5.2 and main (follow-up); the minimal repro fails with -ea and -da on the 10.6.0 release jars (4ae946e5a9b, the commit of tag releases/lucene/10.6.0) and on main 645d4fe14a20; a 9.x-API probe fails on 9.5.0 and 9.12.3 and shows the boundary on 9.4.2; the verifiers executed main (-ea and -da), 10.5.2, 9.12.3 and 9.5.0 with their own tests. [read] the document() body is logically identical in every release from 9.5.0 (9.x explicit casts, 10.x instanceof patterns); upstream main a95c754ee003, branch_10x and branch_10_6 have the byte-identical loop (not executed) and no open PR changes it. JDK 25.0.3, aarch64; pure Java logic.
- 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 “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 (searched 2026-10-27, read-only; the prior-report record, verdict new): the finder's scripted prior-report search (51 queries) re-read and two new scripted prior-report searches (65 planned queries each over apache/lucene GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web), 56 manual GitHub searches (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr), 27 issues and PRs fetched with all comments, 40 direct mailing-list searches, 9 JIRA searches (LUCENE and SOLR), 5 web searches, the changed files of all 408 open apache/lucene PRs (7 touch MemoryIndex.java and none changes the storedFields().document loop; #16847 touches MemoryIndex.java, StoredFields.java and TestLazyDocument.java but not the loop), and the fix-site history (the loop was added once, by #11999, and is byte-identical on upstream main a95c754ee003, branch_10x and branch_10_6). Closest items, none a report of this bug: #11999 (introduced the loop; its review never discussed needsField cardinality, unstored fields or LazyDocument), SOLR-4589 (2013: a committer wrote that LazyDocument.getField 'assumes it will be called exactly once for each StorableField that exists for that field name, in order' and fails with ArrayIndexOutOfBoundsError on misuse, which is the precondition MemoryIndex breaks), #4382 (LUCENE-3309, the StoredFieldVisitor design: invoked once per field in the document, visiting stored fields only), #6222 (LUCENE-5158) and a 2016 java-user@ thread (per-value, insertion-order calls of index readers), #11652 (LUCENE-10616), and #7462 (LUCENE-6402) and #12109 (other MemoryIndex divergences from an index reader, fixed as bugs). Gaps: keyword searches only; private lists are not searchable.
- Lucene commit tested
- main
645d4fe14a20(11.0.0-SNAPSHOT, commit of 2026-10-28): the one-method repro through gradle in a scratch worktree (stock fails; with fix A it passes, and the wholelucene/memorytest module passes with assertions on and off; repeated in a second, fresh scratch worktree) and through the standalone runner with-eaand-da(seeds DEADBEEF and 5EED7451); the 10.6.0 release jars (4ae946e5a9b, the commit named in the jars' manifest and of the release tagreleases/lucene/10.6.0) with-eaand-da; the intended-behaviour check legs probe on stock, fix A and fix B. Earlier, the hunt ran the finder's test on all 26 releases from 9.5.0 to 10.5.2 and on maina9779ef3c193, where the relevant files are identical.
- Finding record
lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:2242-2275 (storedFields().document: loop over every Info at :2247, needsField at :2248 before the storedValues null check at :2255, all values delivered at :2256-2270), :106 (class javadoc: 'a replacement/substitute for RAM-resident Directory implementations')lucene/core/src/java/org/apache/lucene/index/StoredFieldVisitor.java:79-84 (needsField: 'Before a field is processed ... whether they need that particular field or not, or to stop processing entirely'), :86-94 (Status YES/NO/STOP), :35 (@lucene.experimental); StoredFields.java:72-77 ('Expert: visits the fields of a stored document')lucene/misc/src/java/org/apache/lucene/misc/document/LazyDocument.java:58-68 (getField: 'must be called once for each value of the field name specified in sequence that the values exist'), :107-121 (fetchRealValues: realValues[i] per lazy value, assertion at :113-114 is LFV-697)lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsReader.java:690-722 (needsField once per stored value, only for stored values); lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextStoredFieldsReader.java:144 (same)lucene/misc/src/test/org/apache/lucene/misc/document/TestLazyDocument.java:106-130 (asserts numFieldValues == 1 + NUM_VALUES * FIELDS.length on an index), :204-214 (visitor calls lazyDoc.getField from needsField); lucene/memory/src/test/org/apache/lucene/index/memory/TestMemoryIndex.java:719 (testStoredFields reads only through document(int))lucene/CHANGES.txt:3768 (9.5.0: 'GITHUB#11999: MemoryIndex now supports stored fields. (Alan Woodward)')
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:418, :620-622, :673-675, :739-744, :867-885, :2242-2275a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsReader.java:690-722a Lucene main checkout (645d4fe14a20) lucene/misc/src/java/org/apache/lucene/misc/document/LazyDocument.java:58-88, :95-122
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java and lucene/monitor/src/java (grep MemoryIndex: used for offsets and matching)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/StoredFields.java:73-77a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/StoredFieldVisitor.java:35, :80-84, :88-93a Lucene main checkout (645d4fe14a20) lucene/misc/src/java/org/apache/lucene/misc/document/LazyDocument.java:61-63a Lucene main checkout (645d4fe14a20) lucene/misc/src/test/org/apache/lucene/misc/document/TestLazyDocument.java:193-215a Lucene main checkout (645d4fe14a20) lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:106a Lucene main checkout (645d4fe14a20) lucene/memory/src/test/org/apache/lucene/index/memory/TestMemoryIndex.java:749
- Prior reports
a Lucene clone: git log 645d4fe14a20..origin/main -- lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java (empty); git show origin/branch_10_6:.../MemoryIndex.java (same loop)
- History
a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:3785 (GITHUB#11999: MemoryIndex now supports stored fields)https://github.com/apache/lucene/pull/11999
- TestMemoryIndexStoredFieldVisitorCalls.java (lucene/memory test, one method):
repro/LFV-745/src/org/apache/lucene/index/memory/TestMemoryIndexStoredFieldVisitorCalls.java(56 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and traced runs of the repro's document.
assets/LFV-745/bug.png(353 KB)
