All findings · Lucene finding · LFV-118 · frozen-type-cache-skips-check-for-multi-type-fields

Since 10.5.0, IndexWriter accepts a document that omits part of a field built from several field types

Since 10.5.0, IndexWriter accepts a document that omits part of a multi-type field, such as StringField("id") without the field's SortedDocValuesField("id"), which 10.4 rejects; sorts and range queries can then silently miss it. A 10.5.0 cache treats the field's first value type as its whole schema.

Summary

Priority
Medium, rank 14A 10.5.0 default-indexing-chain regression that skips the per-field schema check so sorts and IndexOrDocValuesQuery can later silently miss documents, but it needs the application's own invalid subset document.
Impact
wrong error
Since
10.5.0 (#15886, merged 2026-04-07)introduced 2026-04-07
Affected
10.5.0 to 10.6.0 (10.6.0 by reading) and main (9.1.0 to 10.4.0 not affected)
Status
documented
Found by
Alloy modelling of IndexingChain's schema cache, then a JUnit on the stock 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

Lucene requires every document that contains a field to give it the same data structures (postings, doc values, points, vectors), and checks each document against the field's established structures.

Step-by-step diagram of the frozen-FieldType cache bug. Field id is a StringField plus a SortedDocValuesField. Doc 0 = StringField("id", "a") then SortedDocValuesField("id", "a"): both values enter the document's schema, the FieldInfo gets postings DOCS and doc values SORTED, and the cache keeps only StringField.TYPE_NOT_STORED. Doc 1 = StringField("id", "b") only: its first value has the cached type, so the per-field check is skipped and doc 1 is indexed with postings and no doc value. On 10.4.0, with the doc values value first, with an unfrozen type, after a flush, or with the fix, doc 1 is rejected with "doc values type: expected 'SORTED', but it has 'NONE'".
Field id built from a StringField and a SortedDocValuesField: doc 0 gives the field both structures, the cache keeps only the first value's type, and doc 1, with only the StringField, skips the check and is accepted without a doc value; every other path rejects it. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Add doc 0 with StringField("id", "a") and then SortedDocValuesField("id", "a"). Field id now has two structures: postings and sorted doc values.
  2. Add doc 1 with only StringField("id", "b"). You expect IllegalArgumentException ("doc values type: expected 'SORTED', but it has 'NONE'"), as on 10.4.0; main also rejects it when doc 0 lists the doc-values value first, or after a flush.
  3. On 10.5.0, 10.5.1, 10.5.2 and main, doc 1 is accepted with no error, with assertions on or off. So is doc 1 = StringField + StoredField: it is checked, but against doc 0's leftover state.
  4. Why: after doc 0, Lucene caches the type of the field's first value, StringField's shared frozen type, as "validated", and skips the check for every later document whose first value has that type. But the field's schema came from two types.
  5. Effect: an accepted document lacks a structure, so a search that reads it can miss that document. In one segment of 20,000 documents with NumericDocValuesField + LongPoint, a doc-values-only document holding the top value 1,000,000 is accepted, and a default top-1 descending sort of a term query returns 999,999 (seen with that document at positions 10,000 to 19,999, not at 1,500 or 5,000). No error is raised; CheckIndex reports the index clean.

Who is affected

Applications on 10.5.0-10.6.0 (10.6.0 by reading) and main that build one field from several field classes (StringField + SortedDocValuesField, NumericDocValuesField + LongPoint) and sometimes leave one part out. Lucene used to reject that mistake; now it can accept it if the field's last validated document in the in-memory segment started with the same frozen type. Sorts and queries can then miss it.

Mechanism, terms and numbers748 words

Lucene requires every document that has a field to give that field the same data structures (LUCENE-9334, 9.0). A documented, ordinary way to build a field is from several Field instances under one name, for example a StringField plus a SortedDocValuesField, as StringField's javadoc suggests. Since 10.5.0, a later document that carries only one part is accepted when that part's type was the first value of the field in the most recently validated document of that field in the same in-memory segment; 9.1.0-10.4.0 reject it. The frozen-FieldType cache added by #15886 caches the first value's type after the document was validated and then skips validation for later documents whose first value has that type. Whether Lucene enforces its rule now depends on value order, frozen vs unfrozen types and flushes. The same fast path keeps leftover per-document schema state, so after a rejected addBatch the next valid multi-type document is rejected once. The index stays valid; queries and sorts that use the missing structure can silently miss the document.

Terms

  • Field schema / FieldInfo: the data structures a field has in a segment, fixed by the first document that contains it (MIGRATE.md, "Require consistency between data-structures on a per-field basis").
  • Multi-type field: one field name filled by several Field instances of different types in a document, for example StringField (postings) + SortedDocValuesField (doc values). The field's schema is the union of the types.
  • Frozen type: a FieldType after freeze(). Stock classes share static frozen types, for example StringField.TYPE_NOT_STORED, TextField.TYPE_*, StoredField.TYPE, the *DocValuesField.TYPE constants and KeywordField's types; an application can also reuse its own frozen FieldType (for example one from KnnFloatVectorField.createFieldType). LongPoint, IntPoint and the default vector-field constructors build a new type per instance.

What the code does (main ea57360912ed, IndexingChain.java)

  1. For each document, processDocument builds a per-document schema for every field from all its values and initAndValidateFields compares it with the FieldInfo (:611-640, :683-697, assertSameSchema :2290-2304). The FieldSchema javadoc (:2154-2161): "If there is inconsistency, we raise an error."
  2. Since 10.5.0 (#15886), PerField.reset runs for the field's first value and records its type. If it is the cached validatedFrozenFieldType, it only updates the doc id (resetJustDocId, :1821-1831, :2272-2274), the field is not validated (:622-624) and the value is not added to the schema (:633-635).
  3. A later value forces validation only if its type differs from the cached one (multiValueForcesDeoptimize, :625-630, :1833-1835), and it is merged into whatever schema state the previous operation left.
  4. After validation, trySetValidatedFrozenFieldType caches the first value's type if it is frozen (:695, :1837-1843), although the FieldInfo was checked against the union of all values.

Walk-through

  • doc 0 = [StringField, SortedDocValuesField]: no cache yet, so both values enter the schema; FieldInfo = postings DOCS + doc values SORTED; StringField.TYPE_NOT_STORED is cached.
  • doc 1 = [StringField]: first value has the cached type, so no schema update and no validation. Indexed with postings and no doc value.
  • doc 1 = [StringField, StoredField]: the StoredField deoptimises, but the schema still holds doc 0's DOCS + SORTED, and a stored value adds nothing to it, so the check passes.
  • With doc 0 = [SortedDocValuesField, StringField] the cached type is SortedDocValuesField.TYPE: a StringField-only document is rejected, a SortedDocValuesField-only one is accepted.

Six cases, executed

doc 0doc 1main, 10.5.210.4.0, fix
AStringField, SortedDocValuesFieldStringFieldacceptedrejected
BSortedDocValuesField, StringFieldStringFieldrejectedrejected
CSortedDocValuesField, StringFieldSortedDocValuesFieldacceptedrejected
Das A, then flush()StringFieldrejectedrejected
Eunfrozen copy of the StringField type, SortedDocValuesFieldthe unfrozen typerejectedrejected
FStringField, SortedDocValuesFieldStringField, StoredFieldacceptedrejected

The batch face

processBatch resets the field's schema and fills it from the batch columns (:756-783) but never clears the cache; a rejected batch throws before the cache is updated (:693, :695). After addDocument([StringField("id")]), an addBatch with a NumericDocValuesField.TYPE column for id is correctly rejected, which leaves the batch's state (index options NONE, doc values NUMERIC) in the schema. The next document [StringField, StoredField] takes the fast path for its StringField, deoptimises on the StoredField, and is compared using the batch's leftovers: index options: expected 'DOCS', but it has 'NONE'. One false rejection per rejected batch; the next document is validated from scratch.

Exposure and likelihood513 words
  • Who is hit. Default IndexWriterConfig and codec, assertions on or off. The cached first value must be a shared frozen type (for example StringField, TextField, StoredField, the *DocValuesField constants, KeywordField, or a frozen FieldType the application reuses), so "point first" does not trigger it. Examples: StringField + SortedDocValuesField (StringField's javadoc: "If you also need to sort on this field, separately add a SortedDocValuesField"), NumericDocValuesField + LongPoint (the numeric sort optimisation), SortedNumericDocValuesField + LongPoint (the IndexOrDocValuesQuery pattern), StoredField + doc values.
  • What it takes. A later document in the same in-memory segment that omits one of the field's structures. That document is the application's own mistake; Lucene documents that it rejects it, and every tested release from 9.1.0 to 10.4.0 does.
  • Sort consequence (executed). One segment of 20,000 documents, each with StringField("k", "x"): 19,999 with NumericDocValuesField + LongPoint on ts (doc-values value first; document 1 holds 999,999) and, as document 15,000, one with only NumericDocValuesField("ts", 1000000), the highest value. It is accepted; a default top-1 descending sort of the term query k:x (search(query, 1, sort)) returns 999,999, while the same search without skipping returns 1,000,000. Default codec, plain main(), main -ea/-da and 10.5.2. MIGRATE.md: the sort optimisation "relies on the assumption that the same data is stored in these points and doc values."
  • Conditions of the sort consequence. Whether the document is missed depends on where it falls: at positions 10,000 to 19,999 (five tried) the default search returns 999,999, at 1,500 and 5,000 it finds it, and MatchAllDocsQuery with the same sort found it at every position tried. The verifier's JUnit shows the miss with seed 5EEDF00D; with 15 other seeds its default search did not skip at all and returned 1,000,000.
  • Query consequence (executed). 5,000 documents with SortedNumericDocValuesField + LongPoint on n, then a doc-values-only document with value 7: IndexOrDocValuesQuery(n in [7, 1500]) alone does not match it (points path), but id:dv-only AND the same query does (doc-values path); the same with 6 test seeds and codecs, while 10.4.0 rejects the document. IndexOrDocValuesQuery requires its two queries to match the same documents, so this is the bypass letting the application's violation through, not a defect of that query.
  • Not corruption. CheckIndex is clean before and after forceMerge(1); each structure holds exactly the documents that carried it (a probe of 350 documents: id postings 350, sorted doc values 300). On this bug's path an accepted document can lack a checked structure, but a value with a conflicting checked schema dimension (index options, norms, term vectors, doc values, points, vectors) is still rejected; stored status and attributes are not part of the check, and the sibling LFV-127 is a separate path.
  • The batch face. With the experimental IndexWriter#addBatch, a schema-rejected batch makes the next valid multi-type document of that field fail once (measured: 1 of 50 per rejected batch, in each of 3 rounds), then it heals.
  • Scale. Two documents are enough.
  • Status. Regression in 10.5.0; still on main and branch_10x, and in 10.6.0 (released 2026-10-29).

Minimal repro

The test shows that after a document whose field id is a StringField followed by a SortedDocValuesField, the frozen-FieldType cache lets a second document with only the StringField id skip the per-field schema check (TestFrozenTypeCacheMultiTypeField#testDocumentWithoutDocValuesRejected).

TestFrozenTypeCacheMultiTypeField.java (JUnit test)27 lines
package org.apache.lucene.index;

import java.util.List;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.SortedDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestFrozenTypeCacheMultiTypeField extends LuceneTestCase {

  public void testDocumentWithoutDocValuesRejected() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      w.addDocument(
          List.of(
              new StringField("id", "a", Field.Store.NO),
              new SortedDocValuesField("id", new BytesRef("a"))));
      // expected IllegalArgumentException (doc lacks the doc values "id" has), actual: accepted
      expectThrows(
          IllegalArgumentException.class,
          () -> w.addDocument(List.of(new StringField("id", "b", Field.Store.NO))));
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-118/, 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: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.TestFrozenTypeCacheMultiTypeField
Expected vs actual
Expected IllegalArgumentException (the LUCENE-9334 per-field consistency rule; 10.4 and main with the other value order reject it); actual: the document is accepted (Expected exception IllegalArgumentException but no exception was thrown), with -ea and -da.

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

More files, environment, full output37 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
    => junit.framework.AssertionFailedError: Expected exception IllegalArgumentException but no exception was thrown
       org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
       org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
       org.apache.lucene.index.TestFrozenTypeCacheMultiTypeField.testDocumentWithoutDocValuesRejected(TestFrozenTypeCacheMultiTypeField.java:22)

Why it looks unintended

  • MIGRATE.md (LUCENE-9334): every later document with the field "must also have" the same structures; the FieldSchema javadoc: "If there is inconsistency, we raise an error."
  • 10.4.0 rejects the document; so does main with the other value order or after a flush, and upstream's TestPerFieldConsistency fails if only its loop order is reversed.
  • #15886 was an optimisation: "this work is redundant".
The full argument404 words

The contract, in its own words (at ea57360912ed):

  • MIGRATE.md:1557-1564: "The per field data-structures are implicitly defined by the first document indexed that contains a certain field. ... if you first index a document where a certain field is indexed with doc values and points, all subsequent documents containing this field must also have this field indexed with only doc values and points." CHANGES (9.0.0): "LUCENE-9334: Require consistency between data-structures on a per-field basis."
  • IndexingChain.FieldSchema javadoc (:2154-2161): "If there is inconsistency, we raise an error. This ensures that a field has the same data structures across all documents."
  • TestPerFieldConsistency#testDocWithMissingSchemaOptionsThrowsError: "the same segment: indexing a doc with a missing field throws error", for each of four structures.

Why upstream's test misses it. It drops the fields first to last. Its first rejected document drops field 0, the frozen postings type, so that document's first value has another type, which clears the cache, and the later documents are fully checked. Reversed, the first rejected document still starts with the cached type and is accepted: the reversed copy fails on main, 10.5.0, 10.5.2 and #15886's own commit, and passes on 10.4.0, 9.12.3, #15886's parent and with the fix.

Intent of #15886 (commit message): "For the common case where a field consistently uses the same frozen FieldType instance, this work is redundant — a frozen type is immutable, so its schema contribution is identical every time." "If a different type is encountered, the cache is invalidated and the full validation path runs." "A deoptimize path handles multi-valued fields where a later value uses a different type than earlier values within the same document." CHANGES lists it under 10.5.0 Optimizations. The PR had one approval and no discussion of fields made of complementary types; its tests and those of #16171 cover multi-valued fields whose types share one schema or conflict inside one document. The author's first PR comment names the motivation: light-indexing workloads where schema validation dominates processDocument; no difference in the luceneutil benchmarks, and a 10% documents-per-second gain in doc-values-oriented macro runs of Elasticsearch, together with another change.

Old behaviour. 9.1.0, 9.12.3, 10.0.0, 10.3.2, 10.4.0 and #15886's parent reject every subset document; #15886 and every later release accept them.

Context. In LUCENE-10125 (2021) a committer wrote that "the schema definition of a field to be splitted across multiple Field instances makes it challenging to improve" the LUCENE-9334 check: the case the cache gets wrong.

The devil's-advocate check245 words

The cache is a deliberate hot-path optimisation keyed by object identity (the PerField comment says it lets IndexingChain "skip schema validation if fields keep using the same frozen field type"); the subset document breaks the documented rule anyway (garbage in); the LUCENE-9334 issue called the check "best-effort"; addBatch is @lucene.experimental.

Why it fails: Lucene documents that it rejects such input, upstream tests assert it, and 9.1.0-10.4.0 did so deterministically. The "best-effort" list in the LUCENE-9334 issue (#10374) names this very case: "it is illegal for a document to only enable points and another document to only enable doc values". "Best-effort" does not cover an outcome that flips with value order, frozen vs unfrozen types and flush timing. #15886 presented the change as removing redundant work, not as relaxing the rule. The comment's premise (the field keeps using one frozen type) is false for a field built from several types. And an experimental API does not license a rejected call making a later valid addDocument fail.

What a maintainer could still raise (the cross-model fact check rated the intended case moderate for the subset document and weak for the other two faces): detection may have been meant as a defence rather than a guarantee, and the triggering document is the application's own mistake; a document that omits the field entirely is always legal, while this one has the field with only part of its structures; and the two faces have separate causes and fix parts.

Suggested fix

Recommended Cache a type only if it was the field's only type in the document; rebuild before deoptimising

Don't cache a frozen type when the field used several types in the document (a candidateMixed flag), and rebuild the schema from the cached type before deoptimising: +19/-6 lines in IndexingChain. Valid multi-type documents already run the full validation on main, so they cost almost nothing more. The repro, the regression tests and all 8,870 :lucene:core tests pass.

} else {  // a later value of a field already seen in this document
  if (fieldType != pf.candidateFieldType) {
    pf.candidateMixed = true;  // several types: the first one alone does not describe the field
  }
  if (pf.multiValueForcesDeoptimize(fieldType)) {
    pf.schema.reset(docID);   // rebuild from the earlier values, all of the cached type
    updateDocFieldSchema(fieldName, pf.schema, pf.validatedFrozenFieldType);
    pf.validatedFrozenFieldType = null;
    fields[fieldsNeedInitOrValidate++] = pf;
  }
}
// PerField.reset: candidateMixed = false;
// trySetValidatedFrozenFieldType: cache only if candidateMixed == false
All options and trade-offs4 options · 908 words

The fix has two independent parts, one per face, confirmed by running each part alone: the "mixed" flag alone fixes the row cases but not the batch face; clearing the cache in processBatch alone fixes the batch face but not the row cases. Option A was validated with gradle in a scratch worktree at ea57360912ed (removed after the runs), including the whole :lucene:core suite; option B with gradle on the closest suites; C with a one-line probe; D was not written.

Cache a type only if it was the field's only type in the document; rebuild before deoptimising (validated by the checks) (recommended)

In processDocument, a later value whose type differs from the first value's sets candidateMixed; trySetValidatedFrozenFieldType caches only when candidateMixed == false; on deoptimisation the schema is reset and rebuilt from the cached type (all earlier values in the document had it) before the new value is added.

} else {  // a later value of a field already seen in this document
  if (fieldType != pf.candidateFieldType) {
    pf.candidateMixed = true;  // several types: the first one alone does not describe the field
  }
  if (pf.multiValueForcesDeoptimize(fieldType)) {
    pf.schema.reset(docID);   // rebuild from the earlier values, all of the cached type
    updateDocFieldSchema(fieldName, pf.schema, pf.validatedFrozenFieldType);
    pf.validatedFrozenFieldType = null;
    fields[fieldsNeedInitOrValidate++] = pf;
  }
}
// PerField.reset: candidateMixed = false;
// trySetValidatedFrozenFieldType: cache only if candidateMixed == false

Trade-offs

  • Almost no cost for valid documents of a field that always carries the same several types: on main every such document already deoptimises and is fully validated, because its second value's type differs from the cached one (IndexingChain.java:625-630, :1833-1835). On main its first value still takes the fast path; with the fix each such document does one schema.reset and one more updateDocFieldSchema call (a few assignments and comparisons). Not benchmarked.
  • The documents that lose the fast path are single-type documents right after a multi-type document of the same field. When the other type adds a structure, these are the subset documents the bug lets through. When it adds none (StringField + StoredField, or two types with the same schema), the first valid single-type document after a multi-type one is validated instead of skipped, as on 10.4.0; the next one is cached again. Not benchmarked.
  • The rebuild does not depend on every other path (such as processBatch) clearing the cache when it leaves a foreign schema state behind. It uses FieldSchema.reset, which does not clear the skip-index type; that is the separate bug LFV-116, with its own fix.

What the checks validated

  • Gradle, scratch worktree at ea57360912ed: the repro and the reverse-order TestPerFieldConsistency test fail on stock (7 tests, 4 failed, seeds DEADBEEF and 1234ABCD) and pass with the fix (both seeds, and with -Ptests.asserts=false); checkGoogleJavaFormat clean.
  • TestIndexOptions (the #15886 and #16171 cache tests), TestFieldInfos, TestColumnBatchIndexing, TestIndexWriterExceptions, TestDocValuesIndexing, TestPerFieldConsistency and the new class: 135 tests, 0 failures, 3 skipped. The whole :lucene:core:test (seed DEADBEEF): 748 classes, 8,870 tests, 0 failures, 322 skipped.
  • Earlier in the hunt: the 6-test repro and the reversed upstream test pass; in the bounded-exhaustive harness this fix alone drops the 352 accepted-invalid sequences (of 246,848: 928 first documents x 133 single-field second documents, with and without a flush) and the 292 valid-rejected ones (of 123,424 document/batch/document triples) to 0; mismatch classes of the sibling bugs LFV-116 and LFV-127 remain until their fixes are added (all three together: 0). An Alloy check of the fixed cache found no counterexample in a small universe without addBatch; the larger checks timed out.

The same flag, plus clearing the cache in processBatch (no rebuild) (validated by the checks)

Instead of the rebuild, add pf.validatedFrozenFieldType = null; next to pf.schema.reset(baseDocID) in processBatch (IndexingChain.java:761); +13/-6 lines.

// processBatch, first column for this field name in the batch
pf.columnFeatures = (byte) columnFeatures;
pf.validatedFrozenFieldType = null;   // the batch replaces the field's per-document schema
pf.schema.reset(baseDocID);

Trade-offs

Simpler, and it sits on the line where the sibling bug LFV-127 (a rejected document's type promoted by addBatch) also needs pf.candidateFieldType = null;, so both batch issues can be fixed in one place. It relies on every path that leaves a foreign schema state also clearing the cache.

What the checks validated

The repro 4/4 and the hunt's repro 6/6 pass; all six probe cases are rejected (fact check A); checkGoogleJavaFormat clean; the seven closest classes: 135 tests, 0 failures, 3 skipped. The full core suite was not run with this variant.

Remove the cache (revert #15886 and #16171) (validated by the checks)

Never cache a frozen type; every document is fully validated, as in 10.4.0.

Trade-offs

Fixes both faces by construction but gives up the validation skip #15886 added for documents that repeat one frozen type. Its author reported no difference in the luceneutil benchmarks and about 10% more documents per second in doc-values-oriented macro runs of a downstream engine, measured together with another change; nothing was benchmarked here.

What the checks validated

A one-line probe that never caches a frozen type makes an independent verifier's row, batch and sort-impact tests and the hunt's repro pass.

Cache the field's whole validated schema instead of one type

For example, remember the set of types of the last validated document and take the fast path only for the same set.

Trade-offs

More general and keeps a fast path for multi-type fields, but more code and per-document bookkeeping. Not written or tested.

How it was found

An AI tool writing an Alloy 6.2 model of IndexingChain's per-field schema checks and their frozen-type cache noticed the problem while reading the code; Alloy then produced 3-document counterexamples against a stateless reference of the per-document rule. A JUnit test on the stock jar and a bounded-exhaustive harness on the real code confirmed it.

The method in full202 words

Method: Alloy 6.2 model plus bounded-exhaustive differential testing (semi-formal: the first signal came from code reading while modelling).

  • The hunting unit modelled the per-segment FieldSchema / PerField state machine of IndexingChain, including the frozen-type cache, addBatch and flushes, with a file:line mapping to the Java code.
  • While writing the cache part, the AI tool saw that the cached type is the first value's type while validation runs on the union. The check RowOnlyStockCacheSound against a stateless reference of the per-document contract was satisfiable with 3-document counterexamples; with the fix, CacheFixLocalCacheSound had no counterexample in a small bounded universe without addBatch (row-only traces; the larger fixed-model checks timed out).
  • A differential harness on the real jar ran every pair of a first document (928 kinds) and a second document that has only field f (133 kinds), with and without a flush in between (246,848 sequences: 352 accepted although invalid), and every document / addBatch / single-field document triple (928 x 133 = 123,424: 292 valid documents rejected); this fix removes both classes, and neither of the two sibling fixes does.
  • The counterexample was minimised to a public-API JUnit test on the stock main jar (4 of 6 fail, -ea and -da).

Prior reports, history and checks

Prior reports

No report found (2026-10-26): every apache/lucene issue, PR and comment since #15886 was opened, all 412 open PRs, the mailing lists, the JIRA archive and downstream repos. #15886 introduced it; #16171 fixed a different cache problem. Not fixed upstream.

Searches4 searches

No report of this defect was found, and no open PR changes the cache. Related, none of them this bug: #15886 (introduced it), #16171 (a follow-up that fixed re-promotion of the cache between same-schema types and widened when caching happens), #15943 / #15946 and #16309 / #16315 (flaky cache tests fixed by forceMerge), #15990 (addBatch, cites #15886 as performance work), LUCENE-10125 / #11162 (a 2021 comment naming the split-schema case as hard to optimise), #10374 (LUCENE-9334, the contract's origin). A sibling finding of this hunt, LFV-127, touches the same cache with a different defect and fix site; both are #15886 follow-ups.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), via a scripted prior-report search2026-10-2630 issue/PR queries (45 planned; capped by GitHub's search rate limit), 3 commit queries, comments of the top hits readonly #15886 and #16171 rank high; neither reports this
apache/lucene, complete enumeration2026-10-26every issue/PR (1,198), conversation comment (2,554) and review comment (2,301) updated since 2026-03-27, the day #15886 was opened, scanned for the cache identifiers and related termsno other text names the cache
Open PRs2026-10-26all 412 open PRs with their changed files; the 7 touching IndexingChain, FieldType, FieldInfos or TestPerFieldConsistency read (#16847, #16734, #16269, #16116, #15330, #13525, #13342)none touches the cache
Mailing lists, JIRA archive, downstream, web2026-10-2632 queries over dev@, java-user@, issues@, commits@; 6 JIRA queries (LUCENE-10125 read in full); downstream searches in Elasticsearch, OpenSearch and Solr; 9 web queriesno report; issues@/commits@ hold only notification mirrors of #15886, #16171, #15943, #16315

History

Introduced in 10.5.0 by #15886 (9e1c6a37c6e, merged 2026-04-07): its parent rejects the documents, the commit accepts them. Affects 10.5.0, 10.5.1, 10.5.2, 10.6.0 (by reading) and main; 9.1.0 to 10.4.0 are clean. Not fixed upstream as of 2026-10-26.

Versions tested6 rows

Introduced by #15886 "Cache frozen FieldType to skip schema validation" (by @Tim-Brooks, merged by @ChrisHegarty on 2026-04-07): main 9e1c6a37c6e, branch_10x backport 9a6f19f34b2 (same patch-id). git log -S and git blame put the fast path, the skipped validation and the deoptimise-into-leftover-schema lines there.

  • Widened by #16171 (3ca085e1c77, merged 2026-06-13): it re-caches the first value's type after every validated document; not needed for the row repro.
  • The batch face needs IndexWriter#addBatch, added by #15990 (3d895681557, 2026-05-14).
  • 10.5.0 is the first release with all three. branch_10x and branch_10_6 (10.6.0, released 2026-10-29) have the same code (read, not run). The last IndexingChain.java change upstream is #16708 on main and its backport #16732 on branch_10x and branch_10_6 (both 2026-09-28); neither touches the cache.
  • The commit-level jars are plain javac builds of core, codecs and test-framework, not gradle builds; the release rows use Maven Central jars, SHA-1 checked.
Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed (unreleased 11.0) affectedthe repro fails 3 of 4 (-ea, -da); the hunt's repro 4 of 6
10.5.0, 10.5.1, 10.5.2 affectedthe hunt's repro fails 4 of 6 (10.5.2 also -da; 10.5.1 -ea only); the repro fails 3 of 4 on 10.5.0 (-ea, -da), 10.5.1 and 10.5.2
10.4.0, 10.3.2, 10.0.0, 9.12.3, 9.1.0 not affectedthe row tests pass: the subset documents are rejected (no cache)
9e1c6a37c6e (#15886) vs its parent 86ba05a5982 bisectthe row tests fail 3 of 5 on #15886 (-ea, -da) and pass on the parent
3d895681557 (#15990, first addBatch) affectedthe hunt's repro fails 4 of 6, the batch test included
9.0.0 and 8.x not runthe per-field rule starts in 9.0.0; earlier versions allowed differing structures

Independent checks

The finder, an independent reproducer, an intended-behaviour devil's advocate, reviews by an independent reviewer from another model family (OpenAI GPT-5.6) and an adjudication all found it real, each with its own executed tests; the adjudication re-ran the repros. This brief re-ran the repro and validated the fix with gradle.

Checks and verdicts453 words

Every check wrote or re-ran tests on the stock main jar; none found a convincing case for intended behaviour.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (Alloy model, then JUnit) candidate4 of 6 fail on main (-ea, -da) and 10.5.2; the row tests pass on 10.4.0; 6/6 pass with the fix.
Cross-model review, refute lens real (high)An independent reviewer from another model family (OpenAI GPT-5.6) wrote its own StringField + IntPoint test: fails on main (-ea, -da, 2 seeds) and 10.5.2, passes on 10.4.0 and with the fix.
Independent reproducer realOwn row test fails 3 of 6 on main, 10.5.0 and 10.5.2, passes on 10.4.0 and 9.12.3; own batch test fails; a probe that disables the cache makes every test pass; found the top-1 sort consequence (999,999 instead of 1,000,000).
Works-as-designed review real (high)The same reviewer family, arguing "intended": own contract test fails 2 of 2 on main and passes with the fix; rated the intended argument moderate and rebutted it.
Devil's advocate real (high)No intended or misuse argument survived; its IndexOrDocValuesQuery probe fails on main and 10.5.2 and passes on 10.4.0. Its own sort probe was inconclusive and supports no claim.
Adjudication confirmed (high)Re-ran the finder's repro (-ea, -da), the independent reproduction's row test (main fails, 10.4.0 passes) and the independent reproduction's impact probe (fails).
This brief re-run and fix validatedNew minimal repro (3 of 4 fail on main -ea/-da, 10.5.0, 10.5.2; row tests pass on 10.4.0 and 9.12.3); the fix through gradle, closest suites and the whole core suite; each fix part tested alone.
Fact check A (independent AI tool, executed) real; 7 text errors, fixedReproduced the bug on main, 10.5.0, 10.5.1 and 10.5.2 and the fix on main; re-ran the probe, the regression test and both impact tests; its own default-codec sort probe set the conditions of the sort consequence. Its errors (an imprecise speed-up claim, the sort conditions, diff sizes, harness counts, a backport number, a date, the fix's cost) are corrected.
Fact check B (OpenAI GPT-5.6, reading only) sound; 5 errors, 5 overclaims, 8 caveatsChecked the brief against code and recorded runs without running anything. Its errors and overclaims are corrected and its caveats added (incomplete list of frozen types, the batch path's cache update, the Alloy scope, the harness classes of sibling bugs, the impact tests' codecs).
Diagram check (independent AI tool) pass, no errorsEvery code line, citation, state and message in the diagram matches the code; two extra probes re-ran the on-screen documents. Its six non-blocking notes are answered.

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

Finding record

LFV-118, snapshot of 2026-11-06

Registry title
The frozen-FieldType validation cache (#15886) keys a field on its first value's type, so for a field built from several field types a later document skips the per-field schema check (or is checked against stale state after a rejected addBatch), depending on value order, flushes and earlier operations
Area
index / IndexingChain per-field schema validation (frozen-FieldType cache)
Priority
Medium, rank 14: A 10.5.0 default-indexing-chain regression that skips the per-field schema check so sorts and IndexOrDocValuesQuery can later silently miss documents, but it needs the application's own invalid subset document.
Impact class
wrong error
Impact note
Applications on 10.5.0 and later (default IndexWriterConfig and codec, -ea or -da, ordinary scale) that give one field name several stock field types in a document with a shared frozen type first (StringField then SortedDocValuesField, the pairing StringField's javadoc recommends; NumericDocValuesField then LongPoint; StoredField plus doc values; KeywordField): a later document in the same in-memory segment that omits one of the field's structures is accepted, where 9.0-10.4 reject it and main still rejects it with the other value order, an unfrozen type or a flush in between, and queries or sort optimisations that use the omitted structure can then miss that document with no error (executed: a wrong top-1 of a default descending numeric sort, and an IndexOrDocValuesQuery that misses a document its own conjunction matches); separately, after an experimental addBatch on such a field is rejected, the next valid multi-type document for that field is rejected once; the triggering subset document is itself the application's mistake, and the index stays valid (CheckIndex clean).
Summary
You index documents whose field id is a StringField plus a SortedDocValuesField, adding the StringField first, and then a document that has only the StringField. You expect IllegalArgumentException, as Lucene's per-field consistency rule (LUCENE-9334, MIGRATE.md) requires, as 9.0-10.4 do, and as main still does when the doc-values value comes first, the type is an unfrozen copy, or a flush comes in between. Instead the document is accepted, because the frozen-FieldType cache added in 10.5.0 (#15886) caches the first value's type after the union of all the document's values was validated and then skips the check for every later document whose first value has that type; on the same fast path the schema state is not rebuilt, so after a rejected addBatch a valid StringField + StoredField document fails with 'index options: expected DOCS, but it has NONE'.
Introduced
9e1c6a37c6e3 (#15886 'Cache frozen FieldType to skip schema validation', by Tim-Brooks, merged by ChrisHegarty; 10.x backport 9a6f19f34b29 with an identical patch-id; CHANGES lists it under 10.5.0 Optimizations) [read: git log -S/-G and git blame put the fast path, the skipped validation and the deoptimise-into-previous-schema lines in this commit; exec: the 5 row tests pass on its parent 86ba05a5982 and fail 3 of 5 at 9e1c6a37c6e with -ea and -da, and the reversed-order copy of the upstream TestPerFieldConsistency test behaves the same]; widened by #16171 (3ca085e1c77, 10.x eccb3394382), which re-caches the first value's type after every validated document (not needed for the row repro); symptom 2 also needs IndexWriter#addBatch, added by #15990 (3d895681557, backport #16066 / 3ec3bff01b1), where the full repro fails 4 of 6 [exec]; the skipped check itself, FieldSchema.assertSameSchema, dates from LUCENE-9334 (d03662c48bf, 9.0.0) (2026-04-07 (#15886 merged into main as 9e1c6a37c6e); 2026-05-14 (#15990, addBatch); 2026-06-14 (#16171); 10.5.0 is the first release containing all three)
First affected version
10.5.0 [exec]
Affected versions
10.5.0, 10.5.1, 10.5.2 and main ea57360912ed (unreleased 11.0.0) [exec: the full finder repro fails 4 of 6 on 10.5.0, 10.5.1, 10.5.2 (also with -da) and main (-ea and -da); the 5 row tests fail 3 of 5 on 10.5.0; the reversed-order upstream test fails on 10.5.0, 10.5.2 and main; the independent reproduction's own tests fail on 10.5.0, 10.5.2 and main]; branch_10x and branch_10_6 (10.6.0, released 2026-10-29) carry the same code [read via the contents API, not run]; not affected: 9.1.0, 9.12.3, 10.0.0, 10.3.2, 10.4.0 and the parent commit of #15886, which reject the subset documents (the cache does not exist there) [exec]; 9.0.0 and 8.x not run (the per-field consistency contract starts in 9.0.0); symptom 2 cannot predate 10.5.0 because addBatch did not exist; not fixed upstream as of 2026-10-26 (upstream main head is ea57360912ed; the last IndexingChain.java change on main, branch_10x and branch_10_6 is #16708 of 2026-09-28, which does not touch the cache) [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: Alloy, code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record, read-only: a scripted prior-report search with 30 GitHub issue/PR queries on apache/lucene (45 planned, capped by GitHub's search rate limit), 3 commit, 5 downstream-repo, 32 mailing-list queries over dev@, java-user@, issues@ and commits@, 6 JIRA-archive queries and web; plus a complete REST enumeration of the only possible window, every apache/lucene issue/PR (1,198), conversation comment (2,554) and review comment (2,301) updated since 2026-03-27, the day #15886 was opened; the changed-file lists of all 412 open PRs, with the patches of the 7 that touch IndexingChain.java, FieldType.java, FieldInfos.java or TestPerFieldConsistency.java read (#16847, #16734, #16269, #16116, #15330, #13525, #13342; none touches the cache); 7 manual downstream searches (Elasticsearch, OpenSearch, Solr; one OpenSearch query hit a rate limit and was not retried); the finder's scripted prior-report search and the independent reproduction's and the intended-behaviour check's own searches likewise found nothing); related prior items, none of them this bug: #15886 (introduced it; its review never discusses fields made of complementary types), #16171 (outside follow-up that fixed cache re-promotion between same-schema types and widened when promotion happens), #15943 / #15946 and #16309 / #16315 (flaky cache tests fixed by forceMerge), #15990 (addBatch; cites #15886 as performance work only), LUCENE-10125 / #11162 (a 2021 committer comment that splitting a field's schema across several Field instances makes the LUCENE-9334 check hard to optimise; context that predates the cache, no defect), #10374 (LUCENE-9334, origin of the contract), #15761 (unrelated merge-thread race with the same message text)
Lucene commit tested
main ea57360912ed (commit 2026-10-24; tested 2026-10-26): the JUnit repro and two regression tests with gradle in a scratch worktree, stock (assertions on and off, two seeds) and with the fix, plus 135 tests of the closest suites and the whole :lucene:core suite (8,870 tests) with the fix, and the closest suites with a second fix variant (the worktree was removed after the runs); the repro through the repro runner on main (-ea, -da), 10.5.0 (-ea, -da), 10.5.1 and 10.5.2 (-ea), its row tests on 10.4.0, 10.0.0 and 9.12.3, and a six-case outcome probe on main, 10.5.0, 10.5.2, 10.4.0, 9.12.3, the fix and three fix variants (the brief's runs plus fact check A's re-runs); the hunt's release and commit matrix (9.1.0 to 10.5.2, #15886 and its parent). Upstream main, branch_10x and branch_10_6 had the same cache code on 2026-10-26 (read, not run).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:610-635 (processDocument pass 1: PerField.reset with the first value's type; the field is added to the validation list only when validatedFrozenFieldType is null; multiValueForcesDeoptimize only for a later value of another type; updateDocFieldSchema skipped on the fast path), :683-697 (initAndValidateFields: initializeFieldInfo or assertSameSchema, then trySetValidatedFrozenFieldType), :1798-1801 (PerField comment 'skip schema validation if fields keep using the same frozen field type'), :1821-1843 (reset with resetJustDocId at :1825; multiValueForcesDeoptimize; trySetValidatedFrozenFieldType caches candidateFieldType if it is a frozen FieldType), :2154-2161 (FieldSchema javadoc: 'If there is inconsistency, we raise an error. This ensures that a field has the same data structures across all documents.'), :2199-2204 and :2290-2296 (assertSame, raiseNotSame, assertSameSchema)
  • lucene/core/src/java/org/apache/lucene/document/FieldType.java:95-106 (freeze and isFrozen, the property the cache keys on)
  • lucene/MIGRATE.md:1551-1563 ('Assumption of data consistency between different data-structures sharing the same field name': the numeric sort optimisation relies on points and doc values holding the same data; 'Require consistency between data-structures on a per-field basis': 'all subsequent documents containing this field must also have this field indexed with only doc values and points') and lucene/CHANGES.txt:4707 (LUCENE-9334)
  • lucene/core/src/test/org/apache/lucene/document/TestPerFieldConsistency.java:135-163 (testDocWithMissingSchemaOptionsThrowsError: 'the same segment: indexing a doc with a missing field throws error')
  • lucene/core/src/java/org/apache/lucene/document/StringField.java:22-25 (class javadoc: 'If you also need to sort on this field, separately add a SortedDocValuesField to your document.')
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:611-640, 683-697, 715-783, 1500-1534, 1798-1843, 2154-2161, 2272-2304
  • Lucene main ea57360912ed: lucene/MIGRATE.md:1557-1564; lucene/core/src/java/org/apache/lucene/document/StringField.java:22-26; lucene/core/src/java/org/apache/lucene/document/KeywordField.java:52-62
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/TextField.java:29-43; KnnFloatVectorField.java:70-74; lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2276-2304 (FieldSchema.reset, assertSameSchema)
  • Lucene main ea57360912ed: lucene/MIGRATE.md:1551-1564; lucene/core/src/java/org/apache/lucene/document/StringField.java:22-26; lucene/core/src/java/org/apache/lucene/search/IndexOrDocValuesQuery.java:28-30
Why it looks unintended
  • Lucene main ea57360912ed: lucene/MIGRATE.md:1557-1564; lucene/CHANGES.txt:1058 (10.5.0 Optimizations), :4707 (9.0.0)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1798-1801, 2154-2161; lucene/core/src/test/org/apache/lucene/document/TestPerFieldConsistency.java:42-62, 135-181
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:625-630, 756-783, 1833-1835

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

Repro files

About the diagrams

The diagram follows the six-case probe: every outcome on it was executed on main ea57360912ed (-ea, -da), 10.5.x, 10.4.0, 9.12.3 and the fix, and line numbers refer to IndexingChain.java at that commit. The PNG is the final summary card.

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.