All findings · Lucene finding · LFV-697 · lazydocument-inverted-value-count-assertion

LazyDocument's value-count assertion is reversed: with assertions on, reading a multi-valued lazy field before its last getField call throws AssertionError

With assertions on, LazyDocument throws AssertionError when you read the first value of a two-valued stored field before asking for the second; with assertions off the same calls return the right values. The assertion's condition is written backwards, in every release since 4.2.1.

Summary

Priority
Low, rank 1177A released wrong-direction assertion in the opt-in misc LazyDocument throws only under -ea and returns correct values with -da.
Impact
minor
Since
4.2.1 (SOLR-4589, commit ad81236c7d5, 2013)introduced 2013-03-20 · over 13 years
Affected
4.2.1-10.6.0 and main, with assertions on only
Status
documented
Found by
semi-formal: bounded-exhaustive check

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

Two getField calls and two reads on one document are enough: the first read trips an assertion whose condition is the wrong way round.

Background: LazyDocument

  1. LazyDocument (module lucene/misc) wraps one document of a reader. getField(fieldInfo) returns a placeholder field whose value is read from the index only when you first ask for it, for example with stringValue().
  2. A field can store several values. You call getField once per stored value, in stored order: the first placeholder stands for the first value, the second for the second.
  3. The first read of a field loads the document and fills every placeholder of that field created so far. If you then ask for another placeholder, getField drops the loaded copy, so the next read loads it again and fills the new placeholder too.
Diagram of the bug. A document stores two values in field f, v0 and v1. LazyDocument.getField(f) creates placeholder 0; reading it calls fetchRealValues, which sees 2 stored values and 1 placeholder and checks realValues.length <= lazyValues.size(), 2 <= 1: with assertions on this throws AssertionError 'More lazy values then real values'. With assertions off the loop fills placeholder 0 with v0, and after the second getField the check is 2 <= 2 and placeholder 1 gets v1. The case the message names, 2 placeholders for 1 stored value, passes the check (1 <= 2) and the loop throws ArrayIndexOutOfBoundsException. The fix swaps the two sides of the check.
One document with f = v0, v1: after the first getField there is 1 placeholder and 2 stored values, so the check 2 <= 1 fails on the first read; after the second getField it is 2 <= 2. The over-request (1 <= 2) passes and overruns the array. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Store one document with two values in field f, v0 and v1, and wrap it in new LazyDocument(reader, 0) (module lucene/misc). getField(f) returns a placeholder whose value is loaded on first read; you call it once per stored value.
  2. Call getField(f) and read it, then call getField(f) again and read that. You expect v0, then v1: getField re-fetches the document when more placeholders are requested after a read, as its own comment says.
  3. With assertions on, the first read throws AssertionError: More lazy values then real values for field: f. The check is realValues.length <= lazyValues.size(): 2 stored values, 1 placeholder so far, and 2 <= 1 is false.
  4. With assertions off the same calls return v0 and v1. The case the message names, more getField calls than stored values (1 value, 2 calls), passes the check (1 <= 2) and fails in the loop right after it with ArrayIndexOutOfBoundsException.
  5. Every release from 4.2.1 (2013) to 10.6.0 and main is affected, with assertions on only. Requesting every value first and reading afterwards, as Solr does, works.

Who is affected

Code that uses the opt-in lucene/misc LazyDocument, runs with assertions on (typically test runs), and reads a value of a multi-valued field before requesting all its values, directly or during a StoredFieldVisitor visit. No other Lucene code uses the class. With assertions off the values are right.

Mechanism, terms and numbers643 words

LazyDocument.getField(FieldInfo) (module lucene/misc) returns a new placeholder on every call; its NOTE asks callers to call it once per stored value of the field, in stored order. The first read of a placeholder calls fetchRealValues, which loads the document and fills every placeholder of that field created so far. Before filling them it asserts realValues.length <= lazyValues.size() with the message "More lazy values then real values for field: " (LazyDocument.java:113-114), but the loop right after it reads realValues[i] for every placeholder (:116-121); since getField only ever adds non-null placeholders (:77-78), the loop is safe exactly when lazyValues.size() <= realValues.length: the condition is the reverse of what the message and the loop need. With assertions on, reading the first value of a two-valued field before the second getField call throws AssertionError, although getField resets the cached document whenever more placeholders are requested after a read (:80-86) and with assertions off the same calls return the right values. The case the message names, more getField calls than stored values, passes the assertion and fails in the loop with ArrayIndexOutOfBoundsException. It came with SOLR-4589 (2013, first released in 4.2.1) and is unchanged up to 10.6.0 and main. Production runs (assertions off) are never wrong; the fix is to swap the two sides.

Terms

  • Placeholder (lazy field): the LazyDocument.LazyField that getField returns. lazyValues is the list of placeholders created so far for one field; realValues is every stored value of that field in the loaded document.
  • Over-request: calling getField more often than the field has stored values. The getField NOTE forbids it ("must be called once for each value of the field name specified in sequence that the values exist", LazyDocument.java:61-63).

The bug (file:line at 645d4fe14a20)

  1. getField (:68-88) appends a new placeholder to the field's list (:71-78) and sets doc = null (:85), with the comment "edge case: if someone asks this LazyDoc for more LazyFields after other LazyFields from the same LazyDoc have been actuallized, we need to force the doc to be re-fetched so the new LazyFields are also populated" (:81-84).
  2. A read such as stringValue() (:170-172) calls getRealValue() (:148-157), which calls fetchRealValues while the value is unset (:149-150). fetchRealValues (:107-122) loads the document if needed (getDocument(), :95-104), takes lazyValues (:110) and realValues = d.getFields(name) (:111).
  3. It asserts realValues.length <= lazyValues.size() with the message "More lazy values then real values for field: " (:113-114), then sets realValue = realValues[i] for every i < lazyValues.size() (:116-121). The loop needs lazyValues.size() <= realValues.length (it skips null placeholders, but getField never adds one, :77-78); the assertion checks the reverse.
  4. So with assertions on it rejects a read made while fewer placeholders than stored values exist, which the loop handles fine, and it lets through the over-request, which the loop does not handle: ArrayIndexOutOfBoundsException at :119, with assertions on as well as off.

The numbers

Stored f = v0, v1. After the first getField(f): 1 placeholder, 2 stored values; the first read asserts 2 <= 1 and fails. With assertions off the loop fills placeholder 0 with v0. The second getField(f) sets doc = null; its read reloads the document, asserts 2 <= 2 and fills both placeholders: v1. Only the first read ever fails, because after the field's last getField call the two counts are equal. Over-request: 1 stored value, 2 calls: 1 <= 2 holds and the loop throws Index 1 out of bounds for length 1.

The same assertion fires when the read happens inside a StoredFieldVisitor visit, before the visitor has requested the field's later values (an independent probe, on main and 10.5.2).

Exposure and likelihood191 words
  • Who is hit. Code that uses LazyDocument (no other Lucene class does: in the tracked sources only LazyDocument.java and TestLazyDocument.java mention it) and runs with assertions enabled and reads a lazy value of a multi-valued field before the field's last getField call, directly or from inside a StoredFieldVisitor. Assertions are typically enabled in test runs; Lucene's own build enables them for about 75% of test seeds.
  • What happens. A deterministic AssertionError at that read. With assertions off the values are right: the hunt's bounded-exhaustive harness found 0 wrong values and 0 exceptions in 2,088,699 checks (1,986,359 value reads plus one whole-document check per schedule).
  • The guard does not guard. The misuse its message names (more getField calls than stored values) reaches an ArrayIndexOutOfBoundsException instead of the intended assertion message, with assertions on.
  • Default vs opt-in. An opt-in module; assertions are off by default in the JVM. No scale is needed: two stored values.
  • How likely. Rare. The usual flow, and Solr's (its stored-field visitor requests every value during the visit and reads afterwards, by reading its source), requests every value first. No public report in the 13 years since 4.2.1.

Minimal repro

One JUnit test, one method: one document with f = v0, v1, then getField and a read, twice. Public API only, ByteBuffersDirectory, deterministic, single thread, about 2 s. It fails with assertions on and passes with them off, which is the bug.

TestLazyDocumentReadBeforeLastValue.java (JUnit, one test)50 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.misc.document;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.FieldInfo;
import org.apache.lucene.index.FieldInfos;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestLazyDocumentReadBeforeLastValue extends LuceneTestCase {

  public void testReadFirstValueBeforeRequestingSecond() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new StoredField("f", "v0"));
        doc.add(new StoredField("f", "v1"));
        w.addDocument(doc);
      }
      try (IndexReader reader = DirectoryReader.open(dir)) {
        FieldInfo fi = FieldInfos.getMergedFieldInfos(reader).fieldInfo("f");
        LazyDocument lazy = new LazyDocument(reader, 0);
        // expected v0 then v1; actual (-ea): AssertionError from fetchRealValues on the first read
        assertEquals("v0", lazy.getField(fi).stringValue());
        assertEquals("v1", lazy.getField(fi).stringValue());
      }
    }
  }
}
Command
cp TestLazyDocumentReadBeforeLastValue.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/document/
cd $LUCENE && ./gradlew :lucene:misc:test --tests org.apache.lucene.misc.document.TestLazyDocumentReadBeforeLastValue -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (the test then passes: v0, v1)
Expected
v0 from the first read and v1 from the second, as with assertions off.
Observed
AssertionError: More lazy values then real values for field: f at LazyDocument.java:113, on the first read, with assertions on.

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

More files, environment, full output242 words

The test sits in org.apache.lucene.misc.document, LazyDocument's package, by Lucene test convention; it uses only public API (LazyDocument(IndexReader, int), getField(FieldInfo) and stringValue() on the IndexableField it returns; it never names LazyDocument.LazyField or getDocument(), which are marked @lucene.internal). The outcome does not depend on the seed or on the test framework's random codec.

Not shown by this one test, all from the same assertion and executed elsewhere: a read in the middle of a StoredFieldVisitor visit, and the over-request that reaches ArrayIndexOutOfBoundsException.

As the regression test: the same test unchanged, for example as a new method of TestLazyDocument. It fails on stock main with assertions on and passes with fix A with assertions on and off (gradle). :lucene:misc:check -x test passes with fix A and this test in place.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 10.6.0; every release from 4.2.1 to 10.6.0 is affected with assertions on; 4.0.0 and 4.2.0 are not
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestLazyDocumentReadBeforeLastValue.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/document/
cd $LUCENE && ./gradlew :lucene:misc:test --tests org.apache.lucene.misc.document.TestLazyDocumentReadBeforeLastValue -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (the test then passes: v0, v1)

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (with =false the test passes; the same on 10.6.0)
TestLazyDocumentReadBeforeLastValue > testReadFirstValueBeforeRequestingSecond FAILED
    java.lang.AssertionError: More lazy values then real values for field: f
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:73F8C0A28644819C]:0)
        at org.apache.lucene.misc.document.LazyDocument.fetchRealValues(LazyDocument.java:113)
        at org.apache.lucene.misc.document.LazyDocument$LazyField.getRealValue(LazyDocument.java:150)
        at org.apache.lucene.misc.document.LazyDocument$LazyField.stringValue(LazyDocument.java:171)
        at org.apache.lucene.misc.document.TestLazyDocumentReadBeforeLastValue.testReadFirstValueBeforeRequestingSecond(TestLazyDocumentReadBeforeLastValue.java:45)

1 test completed, 1 failed

Why it looks unintended

  • The message names the unsafe case, more placeholders than stored values; the condition rejects the opposite, harmless one.
  • getField supports this order on purpose: it drops the cached document so later placeholders get filled (LazyDocument.java:81-85).
  • SOLR-4589, which added the assertion, named over-requesting as the hazard; before it, the same calls passed with assertions on (4.0.0, 4.2.0).
The full argument308 words
  • Message vs condition. "More lazy values then real values" means lazyValues.size() > realValues.length, the only state in which the loop at :116-121 can index past realValues. The condition rejects realValues.length > lazyValues.size() instead, which the loop handles fine.
  • The code supports the order. getField resets the cached document for exactly this "edge case" (:81-85), and the next read fills the new placeholder; with assertions off the values are correct.
  • Same commit, same style. The two assertions added beside it word their messages as the failure: assert hasBeenLoaded() : "field value was not lazy loaded" and assert realValue.name().equals(name()) : "realvalue name != name: ..." (:152-154).
  • The author's stated intent (SOLR-4589). On 2013-03-16, before the patch, they wrote that a client app generating LazyField instances for the same field twice "will most likeley get an ArrayIndexOutOfBoundsError". On 2013-03-19, asked whether the re-fetch edge case happens in Solr: "no, but the way the public API for LazyDocument is defined, it is possible ... so i wanted to protect against it -- i included test randomization to verify that the edge case works." Nobody in the thread discusses the condition itself; reading it as reversed is an interpretation of the code. The quotes show that the author meant later getField calls after a read to work; they do not say whether such a call was expected for the same field or for another one.
  • Never tested. Upstream TestLazyDocument requests every value inside the visitor before reading (TestLazyDocument.java:101-104, :203-215), and its step commented "use the same LazyDoc to ask for one more lazy field" builds a new LazyDocument for a different field (:161-165).
  • History. 4.0.0 and 4.2.0 pass the same read order with assertions on; 4.2.1, whose only LazyDocument change is the SOLR-4589 backport, fails; 4.2.1 with only the condition flipped passes.
The devil's-advocate check181 words

The strongest case for "intended" (the cross-model reviews and the adjudication rate it moderate; the independent reproducer, the devil's advocate and the audit rebut it without rating it): the getField NOTE ("must be called once for each value of the field name specified in sequence that the values exist", :61-63) can be read as "request every value before reading any"; the author called the API "really only usable from a StoredFieldVisitor"; Solr and the upstream test always request everything first. On that reading the condition is deliberate and only the message is wrong.

Why it fails: no javadoc, CHANGES.txt or MIGRATE.md text states a timing rule (none explicitly allows early reads either), and getField explicitly handles requests after a read, which the author said they wanted to protect. Even on the strict reading the assertion is wrong, because it lets through the over-request its message describes. Verdict: not intended; an inverted assertion of low impact. Residual risk: a maintainer may read the API as meant only for visitors; the re-fetch branch, the message and the AIOOBE argue against that reading.

Suggested fix

Recommended A. Swap the two sides of the assertion

Swap the two sides: assert lazyValues.size() <= realValues.length, and "then" to "than" in the message. Nothing changes with assertions off, and an over-request now gets the assertion message. Validated: the repro passes with assertions on and off, and the whole misc test suite and its static checks pass.

-    assert realValues.length <= lazyValues.size()
-        : "More lazy values then real values for field: " + name;
+    assert lazyValues.size() <= realValues.length
+        : "More lazy values than real values for field: " + name;
All options and trade-offs3 options ยท 409 words

Fix A was run through gradle in a scratch worktree of 645d4fe14a20; B and C are alternatives, not tested.

A. Swap the two sides of the assertion (validated by the checks) (recommended)

One line in fetchRealValues (LazyDocument.java:113-114), plus the message typo.

-    assert realValues.length <= lazyValues.size()
-        : "More lazy values then real values for field: " + name;
+    assert lazyValues.size() <= realValues.length
+        : "More lazy values than real values for field: " + name;

Trade-offs

  • For every state getField can create (non-null placeholders only, LazyDocument.java:77-78) it accepts exactly the states the loop can handle and rejects the one it cannot, so the over-request now gets the assertion message instead of an ArrayIndexOutOfBoundsException when assertions are on (an independent reproducer's fix check).
  • No change at all with assertions off. Under the strict reading of the getField NOTE, a read before the last getField call is no longer flagged; it returns the right values, so nothing is lost.

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20 (2026-11-01): the repro fails on stock main with -Ptests.asserts=true and passes with =false; with A it passes with both.
  • With A: upstream TestLazyDocument 5 of 5 iterations with assertions on; the whole lucene/misc test suite with assertions on, 24 classes including the repro, 317 tests, 0 failures, 9 skipped (by the test sources 3 @Nightly and 6 that need an FSDirectory; the gradle log records only the count); :lucene:misc:check -x test (google-java-format, ecj lint of main and test, forbidden APIs) passes with A and the test in place.
  • Earlier, the same fix: the finder's repro and upstream TestLazyDocument; an independent 3-test class 3/0 and upstream TestLazyDocument 5/5; the cross-model reviewers' own tests; the bounded-exhaustive harness with assertions on, 0 wrong values and 0 exceptions in 2,088,699 checks; 4.2.1 with the same flip.

B. Make the over-request a real check

Replace the assertion with if (lazyValues.size() > realValues.length) throw new IllegalStateException(...), so a caller that breaks the NOTE gets a clear message in production too.

Trade-offs

Changes behaviour with assertions off (an IllegalStateException instead of the ArrayIndexOutOfBoundsException), so it is a slightly bigger decision than a bug fix. Not tested.

C. Encode the strict reading: assert equal counts

assert lazyValues.size() == realValues.length.

Trade-offs

Fixes the over-request message but keeps rejecting the read-before-last-getField order that getField's re-fetch branch supports and that works with assertions off. Not recommended; not tested.

Downstream note

Solr's lazy field loading, the known downstream user, requests every value before reading and is not hit; other downstream code that reads early sees only a spurious AssertionError, and only in runs with assertions on.

How it was found

Semi-formal: a bounded-exhaustive harness compared LazyDocument with the stored document over 5,117 small field layouts and random read orders that obey the getField contract. With assertions off it found 0 wrong values in 2,088,699 checks; with assertions on, 99,858 of 102,340 read orders threw this AssertionError.

The method in full196 words

Method: semi-formal (bounded-exhaustive check of the stock jar). A hunt unit auditing the lucene/misc collectors, selectors and document helpers wrote a harness that builds every small document shape (1-3 stored fields of 1-3 values each and every interleaving of the values: 5,117 shapes; each field's value type is drawn at random from six stored types), calls getField once per stored value in stored order (the documented contract), and after every getField call reads a random subset of the placeholders created so far (20 schedules per shape), comparing each value with storedFields().document().

With assertions off: 2,088,699 checks (1,986,359 value reads plus one whole-document check per schedule, 102,340), 0 wrong values, 0 exceptions. With assertions on: 99,858 of the 102,340 schedules threw AssertionError: More lazy values then real values; the shortest was get(f#0) get(f#1) read(f@0) on a three-valued field. With the condition flipped, assertions on: 2,088,699 checks, 0 wrong values, 0 exceptions.

The finder reduced it to a public-API JUnit test. It was then reviewed by a reviewer from another model family (OpenAI GPT-5.6), reproduced independently, argued against by an intended-behaviour devil's advocate, judged, re-run and audited, and cut to the one-test repro on 645d4fe14a20.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits (including all 409 open PRs), the JIRA archive (SOLR-4589 read in full), the Lucene and Solr lists, downstream code or the web (2026-10-27); a refresh on 2026-11-01 found nothing new. Not fixed upstream as of 2026-11-01.

Searches4 searches

Closest items, none a report of this bug: SOLR-4589 (2013, the change that added the assertion; its discussion never touches the condition); Solr-side LazyField issues about other behaviour (SOLR-13034, SOLR-15192, SOLR-15443, SOLR-7953, SOLR-10117 and related); the pre-4.0 FieldsReader lazy-field issues (LUCENE-690 and others, a different class); open PR #16847 (a 438-file PR for long doc ids whose only LazyDocument-related edit is a Math.toIntExact change in TestLazyDocument.java; it does not touch LazyDocument.java) and merged #16255 (a comment typo in LazyDocument.java). The Lucene.NET port carries the same reversed check, unreported; one unofficial Go port writes the corrected direction without any report or discussion. Limits: inline PR review comments are not searched; JIRA only through text queries; GitHub code search covers default branches only; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; all of GitHub2026-10-27four scripted prior-report searches (45 queries each); manual GitHub searches on apache/lucene, apache/solr, apache/lucene-solr, apache/lucenenet, Elasticsearch and OpenSearch; a code search for the message "More lazy values" (80 distinct files compared)no report; the Lucene.NET port has the same check; one unofficial Go port silently corrects it
Open PRs2026-10-27changed files of all 409 open apache/lucene PRsnone touches LazyDocument.java; #16847, a 438-file PR for long doc ids, has one LazyDocument-related edit, a Math.toIntExact change in TestLazyDocument.java
JIRA, mailing lists, web2026-10-27JIRA text queries on all projects plus SOLR-4589 read in full (21 comments); the Lucene and Solr lists; 3 web queriesonly related Solr LazyField issues and the origin discussion
Refresh and upstream branches2026-11-01apache/lucene issues and PRs mentioning LazyDocument updated since 2026-10-26, fetchRealValues and "lazy values"; commits touching the file on main and branch_10_6nothing new; on main the file last changed 2026-06-14 (#16255); main f815e679bf6c and branch_10_6 still have the reversed condition

History

Introduced in 4.2.1 by SOLR-4589 (ad81236c7d5, 2013, a performance fix for lazy loading of multi-valued fields), which added fetchRealValues with the reversed assertion; never changed since. Executed on 13 releases from 4.2.1 to 10.6.0 and on main: all fail with assertions on; 4.0.0 and 4.2.0 pass.

Versions tested6 rows

Introduced by ad81236c7d5 "SOLR-4589: Fixed CPU spikes and poor performance in lazy field loading of multivalued fields" (by Chris M. Hostetter, 2013-03-20, svn r1458887 on trunk; Subversion era, no pull request). It replaced the per-value ordinal lookup with fetchRealValues, added the assertion with the reversed condition, the getField NOTE and TestLazyDocument. Backports 29756e22101 (lucene_solr_4_2, first release 4.2.1, tagged 2013-04-03) and dfd2d1f49cd (branch_4x, 4.3.0). On every released line git log -S on the condition finds only these three commits (the other hits, with --all, are side-branch deletions or rewrites of the file), and the condition is present verbatim in all 119 release tags from 4.2.1 to 10.6.0 (absent at 4.2.0); later edits renamed types (2016), reformatted (2021) and moved the package (9.0.0). Commit isolation: the backport is the only LazyDocument change between 4.2.0 and 4.2.1, and 4.2.1 with only the condition flipped passes.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and passes with them off (gradle and standalone); passes with fix A
10.6.0 affectedminimal repro fails with assertions on, passes with them off (release jars)
4.2.1 first affectedfails with assertions on (LazyDocument.java:115), passes with them off; with only the condition flipped it passes
4.3.0, 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2; main a9779ef3c193 affectedfail with assertions on; the request-all-then-read control passes wherever it was run (all but 4.3.0)
4.0.0, 4.2.0 before SOLR-4589the same read order passes with assertions on (no fetchRealValues, no assertion)
upstream main f815e679bf6c, branch_10_6 readthe same assertion; on main the file last changed 2026-06-14 (#16255, a comment typo)

Independent checks

Confirmed with high confidence: the finder, an independent reproducer, reviews by a reviewer from another model family (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication and a credibility audit all reproduced it. All but the finder, which reported it, and the audit, which confirmed it, called it real; the cross-model reviews and the adjudication rated the intended argument moderate.

Checks and verdicts281 words

Everyone agrees on the root cause, the one-line fix and the low impact: an assertion-only failure, correct values with assertions off, an opt-in module.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedBounded-exhaustive harness on the stock jar (above); JUnit fails with assertions on and passes with them off on main, fails on 10.5.2 and 9.12.3; the fix passes the repro and upstream TestLazyDocument.
cross-model review, refute lens (OpenAI GPT-5.6) real, highOwn test fails with assertions on and passes with them off; its request-all-then-read control passes; the fix passes upstream TestLazyDocument; intended argument moderate.
independent reproducer realOwn 3-test class (3 stored values plus a single-valued field) fails 1/3 with assertions on on main, 10.5.2, 9.12.3 and 8.11.4, passes 3/3 with them off; its over-request probe throws ArrayIndexOutOfBoundsException either way; its fix passes 3/3.
cross-model review, intended lens (OpenAI GPT-5.6) real, highOwn interleaved test fails with assertions on and passes with them off, re-run on a9779ef3c193; intended argument moderate.
intended-behaviour devil's advocate real, highRead SOLR-4589 and all six patch revisions; its mid-visit StoredFieldVisitor probe fails with assertions on on main and 10.5.2 and returns v0, v1, v2 with them off.
adjudication confirmed, highNew seeds: the finder's repro fails with assertions on and passes with them off; the independent test fails 1/3; the fix passes.
re-run and credibility audit confirmedRe-runs on a9779ef3c193 with new seeds reproduce it; impact minor confirmed.
minimal repro and this doc still reproducesThe one-test repro fails on 645d4fe14a20 and on 10.6.0 with assertions on and passes with them off; fix A validated with 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,215 words

Finding record

LFV-697, snapshot of 2026-11-06

Registry title
LazyDocument.fetchRealValues checks its value-count assertion in the wrong direction: with assertions enabled, reading a lazy value of a multi-valued field before that field's last getField call throws AssertionError ('More lazy values then real values'), while the over-request case the message names is not caught and fails with ArrayIndexOutOfBoundsException
Area
misc / document: LazyDocument (lazy loading of stored fields, multi-valued fields)
Priority
Low, rank 1177: A released wrong-direction assertion in the opt-in misc LazyDocument throws only under -ea and returns correct values with -da.
Impact class
minor
Impact note
Hits only JVMs run with assertions enabled (test suites, or applications started with -ea) that use the opt-in lucene/misc LazyDocument and read a lazy value of a multi-valued stored field before making the last getField call for that field (directly, or by reading during a StoredFieldVisitor visit): the read throws AssertionError although with -da the same calls return the right values; the usual flow (request every value, then read) and upstream's TestLazyDocument are not affected, no scale is needed, and the reversed check also lets the forbidden over-request through as ArrayIndexOutOfBoundsException instead of the assertion message.
Summary
For a document whose stored field f has the values v0 and v1, you call LazyDocument.getField(f), read the returned field, then call getField(f) again and read the second one; getField's own comment says the document is re-fetched when more lazy fields are requested after others were loaded, so you expect v0 and then v1. With assertions enabled the first read instead throws AssertionError 'More lazy values then real values for field: f', because fetchRealValues asserts realValues.length <= lazyValues.size() (LazyDocument.java:113-114), the reverse of what its message and the loop right after it (:116-121, which reads realValues[i] once per lazy value) need. With -da the same calls return v0 and v1, and the case the message describes (more getField calls than stored values) passes the assertion and fails with ArrayIndexOutOfBoundsException in both modes.
Introduced
ad81236c7d52fac6f681b670055a22d3e13ab3ee (SOLR-4589, svn r1458887 on trunk, by hossman, 'Fixed CPU spikes and poor performance in lazy field loading of multivalued fields'; Subversion era, no pull request): it replaced the per-value ordinal lookup with fetchRealValues and added the assertion with the reversed condition; backports 29756e22101 (lucene_solr_4_2, r1458959, first release 4.2.1) and dfd2d1f49cd (branch_4x, r1458938, 4.3.0). The condition and message never changed afterwards (later commits only renamed types, reformatted, and moved the package in 9.0.0) [read: git log -S and git log -L in the full-history clone; commit isolation executed: the backport is the only LazyDocument change between 4.2.0 and 4.2.1, 4.2.0 passes the probe with -ea, 4.2.1 fails it, and 4.2.1 with only the condition flipped passes] (2013-03-20)
First affected version
4.2.1 [exec] (4.2.0 and 4.0.0 not affected [exec]; LazyDocument first shipped in 4.0.0-alpha with an ordinal-based implementation that had neither fetchRealValues nor the assertion)
Affected versions
Every release from 4.2.1 to 10.6.0 and main (a9779ef3c193, 645d4fe14a20), only with assertions enabled [exec on 4.2.1, 4.3.0, 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main with era-specific probes; the minimal repro fails with -ea and passes with -da on the 10.6.0 release jars and on main 645d4fe14a20 (2026-11-01); the finder's JUnit fails with -ea on main, 10.5.2 and 9.12.3 and passes with -da; the independent reproduction's test fails with -ea on main, 10.5.2, 9.12.3 and 8.11.4 and passes with -da; the assertion line is unchanged in all 118 release tags from 4.2.1 to 10.5.2 in the history clone (8.8.2-8.11.3 not tagged there) [read]]. With -da every executed version returns the right values. Not fixed: upstream main a95c754ee003 (2026-10-26), branch_10x and branch_10_6 still have the reversed condition, and no open PR touches LazyDocument.java [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 agreed, 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): four scripted prior-report searches (45 queries each over GitHub issues and PRs, commits, downstream repositories, the dev, java-user, issues and commits lists and the JIRA archive), manual GitHub searches on apache/lucene, apache/solr, apache/lucene-solr, apache/lucenenet, Elasticsearch and OpenSearch, a GitHub code search for the message ('More lazy values': 80 distinct files compared), the file lists of all 409 open apache/lucene PRs, JIRA text queries plus SOLR-4589 read in full (description and 21 comments), list searches on the Lucene and Solr lists, and 3 web queries. No issue, PR, JIRA ticket, list post or downstream report mentions the reversed assertion, its message, an AssertionError from fetchRealValues, or reading before the last getField call. Related only: SOLR-4589 (the change that added the assertion; its author warned before the patch that over-requesting gives ArrayIndexOutOfBoundsException and said they wanted to support requesting more lazy fields after others were loaded), Solr-side LazyField issues (SOLR-13034, SOLR-15192, SOLR-15443, SOLR-7953, SOLR-10117 and related), the pre-4.0 FieldsReader lazy-field issues (LUCENE-690 and others, a different class), #16847 (open; touches only TestLazyDocument.java) and #16255 (merged; fixed a comment typo in LazyDocument.java). The Lucene.NET port carries the same reversed check; one unofficial Go port silently uses the corrected direction, with no report or discussion. Not fixed: upstream main a95c754ee003 (2026-10-26). Gaps: inline PR review comments are not searched, JIRA only through text queries, GitHub code search covers default branches of public repositories only, private reports are not searchable.
Lucene commit tested
main 645d4fe14a20 (the minimal repro through gradle with -Ptests.asserts=true and =false, stock and with fix A; fix A with upstream TestLazyDocument (5 iterations), the whole lucene/misc test suite and :lucene:misc:check -x test; standalone runs with assertions on and off and a second seed), the 10.6.0 release jars (SHA-1 checked; assertions on and off), and, by the hunt and its verifiers, main a9779ef3c193 and ea57360912ed and the releases 4.0.0, 4.2.0, 4.2.1, 4.3.0, 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2. LazyDocument.java and TestLazyDocument.java are byte-identical at ea57360912ed, a9779ef3c193 and 645d4fe14a20; upstream main f815e679bf6c (2026-11-01) has the same file and branch_10_6 the same assertion (read).

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/document/LazyDocument.java:38-39 (class javadoc), :61-66 (getField NOTE 'must be called once for each value of the field name specified in sequence that the values exist'), :68 (getField), :81-85 (re-fetch comment, doc = null), :113-114 (assert realValues.length <= lazyValues.size() : 'More lazy values then real values for field: '), :116-121 (loop setting f.realValue = realValues[i] for i < lazyValues.size()), :152-153 (adjacent assertions from the same commit whose messages state the failure); identical in Lucene main ea57360912ed
The finding in 30 seconds
  • lucene/misc/src/java/org/apache/lucene/misc/document/LazyDocument.java:61-63, :68-88, :95-104, :107-122, :148-157, :170-172 (at 645d4fe14a20)
Who is affected
  • build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:240-248 (at 645d4fe14a20)
  • git grep -l LazyDocument (at 645d4fe14a20)
Why it looks unintended
  • lucene/misc/src/java/org/apache/lucene/misc/document/LazyDocument.java:61-63, :80-86, :113-121, :152-154 (at 645d4fe14a20)
  • lucene/misc/src/test/org/apache/lucene/misc/document/TestLazyDocument.java:101-104, :161-165, :203-215 (at 645d4fe14a20)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.

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.