All findings · Lucene finding · LFV-112 · sortingcodecreader-parent-field-cache-assertion

With assertions on, re-sorting an index with document blocks through SortingCodecReader fails its cache assertion for the parent field

Re-sorting an index with document blocks as the SortingCodecReader javadoc describes (wrap each segment, then addIndexes) throws AssertionError: only sort fields should be cached twice but [_parent] is not a sort field with assertions on. With them off it works and is correct; affected: 9.10.0 to 10.6.0 and main.

Summary

Priority
Low, rank 1173Re-sorting a block index through SortingCodecReader and addIndexes trips a cache assertion for the parent field, assertion-only since the -da result is correct.
Impact
minor
Since
9.10.0 (2024, with setParentField)introduced 2024-01-11 · about 2 years
Affected
every release from 9.10.0 to 10.6.0, and main (assertions on)
Status
documented
Found by
a randomized differential test of index sorting, then a JUnit repro

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Background

  1. Document blocks: addDocuments adds children followed by their parent. With index sorting, setParentField makes IndexWriter mark the last document of each block, so the block moves as one unit.
  2. Re-sorting an existing index: wrap each segment with SortingCodecReader.wrap(reader, newSort), which shows its documents in the new order, and pass the wrapped readers to addIndexes of a fresh writer.
  3. To save memory, the wrapped reader keeps only the last doc-values field it rebuilt. With assertions on, it checks that only sort fields are rebuilt twice.
Diagram of the bug: a source index with parent field _parent holds a block c1, p1 (sort 7) and a single document x (sort 3). SortingCodecReader.wrap re-sorts it to x, c1, p1, and addIndexes merges it into a fresh writer. The wrapped reader caches one doc-values field at a time. MultiSorter.sort rebuilds _parent (rebuild 1), then the sort field, so the cache holds sort. The doc-values merge asks for _parent again (rebuild 2); _parent is not a sort field, so with assertions on the check fails with: only sort fields should be cached twice but [_parent] is not a sort field. With assertions off the re-sort completes and the result is correct.
A 3-document block index re-sorted through SortingCodecReader: MultiSorter rebuilds _parent, the cache moves on to sort, the doc-values merge rebuilds _parent again, and the assertion fires. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 3 documents with setParentField("_parent"): a block of child c1 and parent p1 (sort=7), then a single document x (sort=3). Re-sort by sort as the javadoc says: SortingCodecReader.wrap gives x, c1, p1; pass it to addIndexes of a fresh writer with that index sort and the same parent field.
  2. You expect addIndexes to succeed. With assertions off it does, and the result is sorted with the block intact; the same recipe without blocks also works.
  3. Instead, with assertions on (-ea, as in most test runs), addIndexes throws AssertionError: only sort fields should be cached twice but [_parent] is not a sort field, and the destination writer closes.
  4. Why: the wrapped reader keeps only the last doc-values field it rebuilt, and its assertion lets only sort fields be rebuilt twice. Since 9.10.0 a merge reads the parent field twice: MultiSorter.sort to find the blocks, then the doc-values merge to copy it.
  5. Affected: every release from 9.10.0 to 10.6.0, and main; 9.9.2 has no setParentField. Opt-in: index sorting, a parent field, blocks, the experimental SortingCodecReader, and assertions on.

Who is affected

Code that re-sorts an index with a parent field and document blocks through SortingCodecReader + addIndexes, in a JVM with assertions on, when the new sort reorders documents: test suites (Lucene's gradle enables assertions for 74% of seeds), and production JVMs started with -ea. On main the 3-document repro fails in every run. With assertions off nothing fails.

Mechanism, terms and numbers545 words

SortingCodecReader's javadoc documents how to re-sort an existing index: wrap every segment with SortingCodecReader.wrap(reader, sort) and add the wrapped readers to a fresh IndexWriter with addIndexes(CodecReader...). For an index with document blocks and a parent field (IndexWriterConfig#setParentField), that re-sort throws AssertionError: only sort fields should be cached twice but [_parent] is not a sort field with assertions enabled (tested: a block of several documents, a wrap that reorders documents, and a parent field that is not in the sort). The wrapped reader caches only the last doc-values field it rebuilt, and its assertion allows only sort fields to be rebuilt twice; since #12829 (9.10.0) the merge reads the parent field twice, in MultiSorter.sort and in the doc-values merge, and the allow-list was never extended. With assertions off the re-sort completes and the result is correct. Every release from 9.10.0 to 10.6.0 and main is affected; the practical effect is that no passing test of the documented recipe on block indexes can run with assertions on.

The bug, step by step

  1. The wrap computes the new order with Sorter.sort(LeafReader), which since #12829 reads the parent field of the unwrapped segment and sorts each document by its block's parent (Sorter.java:185-199, called from SortingCodecReader.java:446). In the example, c1, p1, x becomes x, c1, p1.
  2. Every doc-values read through the wrapped reader rebuilds the field in the new order (getNumeric -> getOrCreateDV, SortingCodecReader.java:713-716), in the reader's own getNumericDocValues (:793-804): always a long[maxDoc] and a FixedBitSet(maxDoc).
  3. A single-entry cache keeps only the last rebuilt field (SortingCodecReader.java:864-885). Its comment accepts rebuilding sort fields: "we do the work twice for these fields" (:866).
  4. The assertion counts rebuilds and allows a second one only for a field of the reader's sort (:890-918); otherwise "only sort fields should be cached twice but [_parent] is not a sort field" (:914-915). Its comment: "this is mainly there to make sure we change anything in the way we merge we realize it early" (:892).
  5. Rebuild 1 of _parent: addIndexes(CodecReader...) merges the wrapped reader. The merge state calls MultiSorter.sort (MergeState.java:223), which reads the parent field of every reader with blocks (MultiSorter.java:57-63), then the sort field (:84), so the cache now holds sort. (This is the order since 10.5.0; older releases read the sort field first.)
  6. Rebuild 2 of _parent: the doc-values merge asks for every numeric field again (DocValuesConsumer.java:204-216, getNumeric at :215). _parent is not cached and is not a sort field, so the assertion fails. AssertionError is an Error, so addIndexes treats it as a tragic event and the destination writer closes (IndexWriter.java:3390-3392).
  7. With assertions off step 4 does not run: _parent is simply rebuilt twice and the result is correct.

The cache and the sort-only allow-list come from LUCENE-9539 (2020); the parent-field read in MultiSorter from #12829 (2024), which did not touch SortingCodecReader. A pull-counting wrapper below the reader confirmed, after the wrap, the two reads of _parent (MultiSorter.sort:58, DocValuesConsumer.getMergedNumericDocValues:215) and a single read of the sort field (by MultiSorter); during the wrap, Sorter.sort had read each field once (step 1).

Exposure and likelihood197 words
  • What they lose: the re-sort job (the destination writer closes, its last commit intact; the source is only read). No passing regression test of the recipe on block indexes can run on stock code with assertions on: the hunt's own differential test had to skip its SortingCodecReader build for block indexes under -ea because of this bug.
  • Opt-in throughout: index sorting, setParentField, document blocks, the @lucene.experimental SortingCodecReader and assertions on. Assertions are off by default in the JVM.
  • Not affected: IndexWriter's own index-sorted merges of block segments, the recipe without blocks, and readers already in the target order (the wrap then returns a plain FilterCodecReader, SortingCodecReader.java:458-481). BPIndexReorderer also wraps with SortingCodecReader.wrap(reader, docMap, null) but is not affected as Lucene uses it: merge-time reordering is skipped under an index sort (IndexWriter.java:3560, :5555), and without an index sort MultiSorter never runs (MergeState.java:216-223; read, not run).
  • Not claimed: wrong results, corruption, a hang, or a failure in a default production JVM (assertions off); a JVM started with -ea would lose the destination writer. With assertions off the parent field is rebuilt one extra time per wrapped reader; that cost was not measured.

Minimal repro

One JUnit test, one method, one thread, 3 documents, one sort field, public API only: re-sort a block index with SortingCodecReader.wrap and addIndexes. It fails with assertions on and passes with them off or with either fix.

TestSortingCodecReaderParentField.java (one JUnit test, public API)57 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.index;

import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSortingCodecReaderParentField extends LuceneTestCase {

  private static Document doc(long sortValue) {
    Document doc = new Document();
    doc.add(new NumericDocValuesField("sort", sortValue));
    return doc;
  }

  public void testResortBlocksWithAddIndexes() throws Exception {
    Sort sort = new Sort(new SortField("sort", SortField.Type.LONG));
    try (Directory src = new ByteBuffersDirectory();
        Directory dst = new ByteBuffersDirectory()) {
      try (IndexWriter w =
          new IndexWriter(src, new IndexWriterConfig().setParentField("_parent"))) {
        w.addDocuments(List.of(doc(7), doc(7)));
        w.addDocument(doc(3));
      }
      IndexWriterConfig iwc =
          new IndexWriterConfig()
              .setParentField("_parent")
              .setIndexSort(sort)
              .setMergeScheduler(new SerialMergeScheduler());
      try (DirectoryReader r = DirectoryReader.open(src);
          IndexWriter w = new IndexWriter(dst, iwc)) {
        // expected: the re-sort succeeds (as with -da); actual (-ea): [_parent] is not a sort field
        w.addIndexes(SortingCodecReader.wrap((CodecReader) r.leaves().get(0).reader(), sort));
      }
    }
  }
}
Command
cp $REPRO/TestSortingCodecReaderParentField.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestSortingCodecReaderParentField -Ptests.seed=DEADBEEF -Ptests.asserts=true
# passes with -Ptests.asserts=false
Expected
addIndexes re-sorts the index to x, c1, p1 without error, as it does with assertions off.
Observed
With assertions on, addIndexes throws AssertionError: only sort fields should be cached twice but [_parent] is not a sort field and the destination writer closes.

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

More files, environment, full output323 words

The test sits in org.apache.lucene.index next to TestSortingCodecReader but uses no package-private API. The source index has the parent field _parent and no index sort: one block [c1, p1] with sort=7, then a single document x with sort=3. The wrap must reorder (x, c1, p1); an already-sorted reader does not trigger the bug. SerialMergeScheduler keeps the failure on the test thread; with the default ConcurrentMergeScheduler the assertion fires in a merge thread, and in the adjudication's run the test framework reported both the AssertionError and an uncaught MergePolicy$MergeException wrapping it. The test only checks that the re-sort does not throw; the block-order checks with assertions off are in the hunt's and verifiers' tests. On main and 10.4.0 and later it fails in every run; on older releases add -Dtests.codec=<the release's default codec> (e.g. Lucene99 on 9.10.0) to make it fail every time. It passes the core lint as is. The regression test for a fix is this same test.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on, passes with them off; with either fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (environment lines cut)
TestSortingCodecReaderParentField > testResortBlocksWithAddIndexes FAILED
    java.lang.AssertionError: only sort fields should be cached twice but [_parent] is not a sort field
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:785B981BAB2A8772]:0)
        at org.apache.lucene.index.SortingCodecReader.assertCreatedOnlyOnce(SortingCodecReader.java:914)
        at org.apache.lucene.index.SortingCodecReader.getOrCreate(SortingCodecReader.java:879)
        at org.apache.lucene.index.SortingCodecReader.getOrCreateDV(SortingCodecReader.java:921)
        at org.apache.lucene.index.SortingCodecReader$7.getNumeric(SortingCodecReader.java:715)
        at org.apache.lucene.codecs.DocValuesConsumer.getMergedNumericDocValues(DocValuesConsumer.java:215)
        at org.apache.lucene.codecs.DocValuesConsumer$1.getNumeric(DocValuesConsumer.java:194)
        [...]
        at org.apache.lucene.index.SegmentMerger.mergeDocValues(SegmentMerger.java:192)
        at org.apache.lucene.index.SegmentMerger.mergeWithLogging(SegmentMerger.java:318)
        at org.apache.lucene.index.SegmentMerger.merge(SegmentMerger.java:153)
        at org.apache.lucene.index.IndexWriter.addIndexesReaderMerge(IndexWriter.java:3589)
        at org.apache.lucene.index.IndexWriter$AddIndexesMergeSource.merge(IndexWriter.java:3463)
        at org.apache.lucene.index.SerialMergeScheduler.merge(SerialMergeScheduler.java:38)
        at org.apache.lucene.index.IndexWriter.addIndexes(IndexWriter.java:3312)
        at org.apache.lucene.index.TestSortingCodecReaderParentField.testResortBlocksWithAddIndexes(TestSortingCodecReaderParentField.java:53)

# -Ptests.asserts=false: 1 test, 0 failures
# with fix A or fix B: 1 test, 0 failures, assertions on and off
# standalone runs (-ea, seed DEADBEEF): fails in every run on main, 10.6.0, 10.5.2 and 10.4.0; 10.6.0 passes with -da;
# on 9.10.0, 9.12.3 and 10.0.0 only in some runs under the random test codec, and in every run with
# -Dtests.codec=<the release's default codec>;
# 9.9.2: does not compile (no IndexWriterConfig#setParentField)

Why it looks unintended

  • The SortingCodecReader javadoc documents this re-sort recipe and excludes no index shape.
  • #12829 made the re-sort keep blocks together on purpose (Sorter.sort, whose only production caller is SortingCodecReader.wrap).
  • The cache already accepts rebuilding fields the sort needs; the parent field became one in 2024, and the 2020 allow-list was never updated.
The full argument237 words
  • The recipe is documented: "This can be used to re-sort and index after it's been created by wrapping all readers of the index with this reader and adding it to a fresh IndexWriter via IndexWriter#addIndexes(CodecReader...)" (SortingCodecReader.java:48-51). addIndexes throws "cannot change index sort" for segments without the target sort (IndexWriter.java:3158, :3245-3251).
  • Blocks are meant to survive it: #12829 made Sorter.sort(LeafReader) parent-aware (Sorter.java:191-199); its only production caller is SortingCodecReader.wrap (:446). CHANGES for #12829: "Document blocks are maintained alongside their parent documents during sort and merge" (lucene/CHANGES.txt:2978-2982, the 9.10.0 entry; the 10.0.0 entry at :2311-2314 has the same sentence). setParentField says the parent marker "is required" under index sorting (IndexWriterConfig.java:580-589).
  • The code contradicts itself: the cache comment accepts doing "the work twice" for fields the sort needs (SortingCodecReader.java:864-866), and MultiSorter reads the parent field for exactly that purpose. The allow-list (LUCENE-9539, 2020) predates the parent field; #12829's review never mentions SortingCodecReader.
  • No test covers this trigger: no upstream test re-sorts a block index through SortingCodecReader.wrap(reader, sort) and addIndexes into an index-sorted writer. TestBPIndexReorderer sets a parent field (:162) and reaches SortingCodecReader.wrap(reader, docMap, null) through BPIndexReorderer, but with no index sort, so MultiSorter never runs.
  • Precedent: #13479 fixed an NPE inside this assertion when it misfired on legal use; #13478 (open) treats the assertion firing under intra-merge threads as a real problem.
The devil's-advocate check106 words

The strongest case for "intended" (the cross-model reviews, the devil's-advocate verifier and the adjudication): the assertion is a deliberate tripwire for changes in how merges read fields (SortingCodecReader.java:892), the reader is @lucene.experimental and "might be very costly and memory intensive" (:52), and with assertions off the output is correct.

Why it fails: the tripwire fired because #12829 changed the merge's access pattern, and the follow-up (allow the parent field, or stop rebuilding it) was never done. Until one of them changes, no assertion-enabled test can run the documented recipe on a block index. That argues for low severity, not for intended behaviour.

Suggested fix

Recommended A. Allow the parent field to be rebuilt twice (recommended, validated)

Let assertCreatedOnlyOnce also accept the parent field (getFieldInfos().getParentField()), as it accepts sort fields: +7/-2 lines in SortingCodecReader.java. The repro, 4 related suites, the whole core index test package with assertions on and two other-module suites pass, and so does the core lint.

// MultiSorter also reads the parent field, to keep document blocks together, before the doc
// values merge reads it again
boolean isParentField = field.equals(getFieldInfos().getParentField());
...
assert isSortField || isParentField
    : "only sort fields and the parent field should be cached twice but ["
        + field
        + "] is neither";
All options and trade-offs2 options ยท 616 words

Both options were run through gradle in a scratch worktree of 645d4fe14a20. A is the smallest change and keeps today's design; B also removes the extra rebuild at the price of holding the parent field's values longer. Deleting the assertion is not a fix: it drops the tripwire LUCENE-9539 added on purpose.

A. Allow the parent field to be rebuilt twice (recommended, validated)

In assertCreatedOnlyOnce, treat the parent field like a sort field: MultiSorter reads it to keep blocks together, and the doc-values merge reads it again.

// MultiSorter also reads the parent field, to keep document blocks together, before the doc
// values merge reads it again
boolean isParentField = field.equals(getFieldInfos().getParentField());
...
assert isSortField || isParentField
    : "only sort fields and the parent field should be cached twice but ["
        + field
        + "] is neither";

Trade-offs

  • Smallest change, same design: the code already accepts rebuilding fields the sort needs (SortingCodecReader.java:864-866). The "at most twice" check stays, so a third rebuild is still caught.
  • getParentField() may be null; field.equals(null) is false, so writers without a parent field behave as today.
  • With assertions off nothing changes: the parent field is still rebuilt once more per wrapped reader (not measured).
  • Backports: fine from 10.5.0 (one parent read per reader in MultiSorter, #16202). In 9.10.0-10.4.0, with two or more sort fields, MultiSorter reads the parent once per sort field, so the "at most twice" check would still fire there; such a backport needs more (not run).
  • Scope: A fixes this single-threaded trigger only, not #13478, where intra-merge threads evict each other from the single-entry cache.

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20 (--offline, seed DEADBEEF). Stock: the repro fails with assertions on and passes with them off.
  • With A: the repro plus TestSortingCodecReader, TestIndexSorting, TestAddIndexes and TestFilterReadersForwardVectorPrefetch pass with assertions on (5 suites, 109 tests, 0 failures); the repro passes with assertions off; the whole org.apache.lucene.index test package passes with assertions on (1396 tests, 0 failures, 56 skipped); TestBPIndexReorderer (8 tests) and TestIndexSortBackwardsCompatibility (68 tests) pass (assertions at gradle's seed default, on for seed DEADBEEF by calculation, not recorded in the logs; a third parent-field suite outside core, TestMockRandomMergePolicy, was not run); checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass for core with the repro in place.
  • Earlier, the hunt's equivalent sketch passed the finder's, the independent reproducer's and the cross-model reviewers' tests and TestSortingCodecReader (3/3) on main ea57360912ed.

B. Give the parent field its own cache entry, so it is rebuilt once (alternative, validated)

getOrCreateDV keeps the parent field's rebuilt values in a second slot next to the single-entry cache (+14 lines in SortingCodecReader.java); the assertion still counts that single rebuild.

private Object cachedParentObject;

private <T> T getOrCreateDV(String field, IOSupplier<T> supplier) throws IOException {
  if (field.equals(getFieldInfos().getParentField())) {
    synchronized (this) {
      if (cachedParentObject == null) {
        assert assertCreatedOnlyOnce(field, false);
        cachedParentObject = supplier.get();
      }
      return (T) cachedParentObject;
    }
  }
  return getOrCreate(field, false, supplier);
}

Trade-offs

  • Also removes the extra rebuild with assertions off, in the spirit of the TODO in the cache comment (SortingCodecReader.java:864-866).
  • The parent field's long[maxDoc] and FixedBitSet(maxDoc) stay referenced by the wrapped reader from MultiSorter.sort until the reader is released after the merge, so the peak memory of a re-sort can grow by that much. Not measured.

What the checks validated

  • Gradle in the same scratch worktree: the repro plus TestSortingCodecReader, TestIndexSorting, TestAddIndexes and TestFilterReadersForwardVectorPrefetch pass with assertions on (5 suites, 109 tests, 0 failures); the repro passes with assertions off; checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass.
  • Only the repro exercises the new branch; the larger runs of A were not repeated for B.

Downstream note

A code search found no use of SortingCodecReader in Elasticsearch, OpenSearch or Solr, so downstream exposure is code that runs this re-sort itself with assertions on.

How it was found

A randomized differential test of index sorting built the same index four ways (unsorted, index-sorted, re-sorted through SortingCodecReader + addIndexes, pre-sorted) and compared them. With assertions on, every iteration with document blocks failed the SortingCodecReader build; reading the failing frames gave the root cause, and a JUnit test on the stock jars confirmed it. Non-formal.

The method in full118 words

Method: a randomized differential test, then code reading and JUnit (the hunt's "non-formal" tier).

  • A hunt unit (index sorting with document blocks) ran a differential baseline test: the same random operations built unsorted, with IndexWriterConfig.setIndexSort, through SortingCodecReader.wrap of every leaf plus addIndexes, and in pre-sorted order, compared per stored id.
  • In the runs with assertions on, every iteration with document blocks failed the SortingCodecReader + addIndexes build with this AssertionError. With assertions off the four builds agreed.
  • The finder traced the failing frames to the cache assertion and the parent-field read in MultiSorter, and minimised it to a JUnit test on the stock jar; the hunt later reduced that to the one-method test above.

Prior reports, history and checks

Prior reports

No prior report found (181 queries on 2026-10-26, refreshed 2026-11-01) in GitHub issues, PRs and commits, the mailing lists, the JIRA archive or downstream repositories. Closest: #13478 (open), the same assertion failing under intra-merge threads; a different trigger and fix.

Searches4 searches

No issue, PR, commit, list thread, JIRA entry or downstream report describes the parent field tripping this assertion. The exact message appears upstream only in the code and in the commit notifications of apache/lucene-solr#1909 and #13479. #13478 (open) and #13475 show the same assertion firing for a different reason: under intra-merge parallelism, concurrent norms and doc-values merges evict each other from the single-entry cache. Context: #10579 (LUCENE-9539) and apache/lucene-solr#1909 (origin of the cache and assertion), #12829 (added the parent read), #16202 (moved it), apache/solr#4644 (closed, unmerged downstream re-sort that rejects nested documents).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26three scripted prior-report search runs (25, 77 and 17 queries, incl. commits and downstream repositories), e.g. assertCreatedOnlyOnce, "cached twice", "is not a sort field", SortingCodecReader with "parent field"; the git history of assertCreatedOnlyOncerelated only: #13478, #13475, #13479, #10579, #12829, #16202
Open PRs2026-10-26the files of all 412 open PRs; the 7 that change SortingCodecReader, MultiSorter or Sorter read in fullnone changes the assertion, the cache or the double read
Mailing lists, JIRA archive, downstream code, web2026-10-2640 lists.apache.org queries (dev@, java-user@, issues@, commits@, builds@), 7 JIRA queries, GitHub code search in Elasticsearch, OpenSearch and Solr, 7 web queriesno report; no downstream use of SortingCodecReader; the message only in commit notifications
GitHub, refresh2026-11-01items created since 2026-10-25 for SortingCodecReader, assertCreatedOnlyOnce, "cached twice", "not a sort field", "parent field" (GraphQL search, with a positive control)only #16947 (merged; sort-based early termination with document blocks): a different bug

History

Introduced by #12829 (df6bd25ce44, merged 2024-01-11; first released in 9.10.0 through its backport 0aa88910ca9), which added the parent field and its read in MultiSorter next to a 2020 sort-only allow-list. Every release from 9.10.0 to 10.6.0 and main fails with assertions on.

Versions tested7 rows

The single-entry cache and its sort-only allow-list come from LUCENE-9539 (2020; main 17c285d6174, first released in 9.0.0, and its branch_8x commit 427e11c7f64, released in 8.7.0). #12829 added setParentField and the parent-field read in MultiSorter.sort but did not touch SortingCodecReader, so the bug exists from the first commit at which it can be expressed. #16202 (main e6f12f31d08; in 10.5.0 through its backport 5a3ec9ad9d8) moved the parent read out of the per-sort-field loop and before the sort-field read: with two or more sort fields, 9.10.0-10.4.0 already fail inside MultiSorter.sort, later releases in the doc-values merge. The one-test repro fails in every run from 10.4.0 on; on 9.10.0-10.0.0 only in some runs under the test framework's random codec, and in every run with the default codec pinned (the hunt's history probe uses the default codec). Not fixed on any branch as of upstream main 2f424fd428da (2026-10-30).

Versions and branches executed, with results
Version or commitVerdictResult
9.9.2 not affectedno setParentField; the repro does not compile; the re-sort without blocks passes
main df6bd25ce44 / branch_9x 0aa88910ca9 (#12829) vs parents b7728c56571 / 00e2fe6cacb commitboth commits fail; their parents cannot express the scenario
9.10.0 - 9.12.3 affectedfail with -ea (all 7 releases, history probe, default codec); -da correct on 9.10.0 and 9.12.3; the minimal test fails in some runs (9.10.0 4 of 14, 9.12.3 5 of 9) and in every run with the default codec pinned (6 of 6 each)
10.0.0 - 10.5.2 affectedfail with -ea (all 12 releases, history probe, default codec); -da correct on 10.5.2; the minimal test fails in every run on 10.4.0 (8 of 8) and 10.5.2 (9 of 9), and on 10.0.0 in 6 of 9 runs (6 of 6 with the default codec pinned)
10.6.0 affectedthe minimal test fails with -ea in every run (9 of 9), passes with -da
main 645d4fe14a20 affectedthe minimal test fails with assertions on in every run (standalone 9 of 9, gradle), passes with them off
fix A or fix B on 645d4fe14a20 not affectedthe minimal test passes with assertions on and off

Independent checks

The finder, independent verifiers, cross-model reviews (OpenAI GPT-5.6) and an adjudication all reproduced it and called it real; the adjudication confirmed it with high confidence, as a low-severity, assertion-only bug.

Checks and verdicts382 words

Every check reproduced the failure with assertions on and a correct result with them off, and agreed with the root cause and the fix. The only argument was over severity: a stale assertion, not wrong results.

Each independent check and its verdict
CheckVerdictWhat it found
finder (randomized differential test, then JUnit) reproducedIts JUnit fails with -ea on main, 10.5.2, 9.12.3 and 9.10.0, passes with -da (order checked) and with its fix sketch.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real, highOwn test fails with -ea for 5 seeds and on 9.12.3 and 10.5.2; passes with -da; no-parent and already-sorted controls pass; fix agreed.
independent reproducer real, low severityOwn test (6 blocks) fails with -ea for 4 seeds and two codecs, on 10.5.2, 9.12.3 and 9.10.0; correct with -da; a pull-counting wrapper confirms the two reads.
cross-model review, works-as-designed lens (OpenAI GPT-5.6, executed) real, highOwn test fails with -ea, passes with -da; controls pass; the "deliberate tripwire" reading rated moderate and rebutted.
intended-behaviour devil's advocate real, low severityOwn test fails with -ea on main and 10.5.2, exact order with -da; controls: IndexWriter's own sorted merge with blocks, a re-sort without blocks, addIndexes(Directory) refusing a sort change.
adjudication confirmed, highRe-ran the independent reproducer's test with a new seed: fails with -ea (in a merge thread), passes with -da and with the fix.
history and security screen 9.10.0 first affected; security none54 runs over 19 releases, main and four commits; a probe of 5,516 documents with deletions, run with -ea and -da.
this doc's runs (2026-11-01) reproduced, two fixes validatedStandalone runs on 9.10.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main (one run each); gradle on 645d4fe14a20: stock fails with assertions on; fixes A and B pass the repro and 4 related suites; A also the index test package, two other-module suites and the core lint.
fact checks (in-house, and cross-model by OpenAI GPT-5.6, read-only) real; corrections appliedRe-ran gradle on 645d4fe14a20 (fails with assertions on, passes off) and the repro many times: every run fails on main, 10.6.0, 10.5.2 and 10.4.0; on 9.10.0, 9.12.3 and 10.0.0 only some runs fail unless the default codec is pinned. Their corrections are applied.

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

Finding record

LFV-112, snapshot of 2026-11-06

Registry title
With assertions enabled, re-sorting an index with document blocks through SortingCodecReader and addIndexes fails SortingCodecReader's cache assertion for the parent field
Area
index / SortingCodecReader re-sort (index sorting with document blocks)
Priority
Low, rank 1173: Re-sorting a block index through SortingCodecReader and addIndexes trips a cache assertion for the parent field, assertion-only since the -da result is correct.
Impact class
minor
Impact note
Only JVMs running with Java assertions enabled (-ea), such as Lucene's own and downstream test suites, that re-sort an index with document blocks and a parent field through the documented SortingCodecReader.wrap + IndexWriter#addIndexes(CodecReader...) recipe, and only when the wrap actually reorders documents; opt-in (index sort, IndexWriterConfig#setParentField, document blocks, the experimental SortingCodecReader); with -da the re-sorted index is complete and correct; any scale; released since 9.10.0.
Summary
You re-sort an index that uses document blocks and a parent field (IndexWriterConfig#setParentField) the way the SortingCodecReader javadoc describes: wrap each segment with SortingCodecReader.wrap(reader, newSort) and pass the wrapped readers to IndexWriter#addIndexes(CodecReader...) of a fresh writer with the new index sort and the same parent field. You expect the re-sort to succeed, as it does with assertions disabled (the result is then correctly sorted with every block intact) and as it does for an index without blocks. Instead, with Java assertions enabled (-ea), addIndexes throws AssertionError 'only sort fields should be cached twice but [_parent] is not a sort field' and the destination writer closes, because MultiSorter reads the parent field's doc values once to find the blocks and the doc-values merge reads them again, while SortingCodecReader's internal check allows a second read only for sort fields.
Introduced
df6bd25ce44 (#12829 'Add support for index sorting with document blocks', main, first released in 10.0.0); its branch_9x commit 0aa88910ca9 shipped first, in 9.10.0 [read: git log -S getParentField and -S setParentField, git tag --contains; exec: lucene-core compiled from source at each of the two commits fails both bug scenarios of the history probe, while at their parents b7728c56571 and 00e2fe6cacb the scenario cannot be expressed (no setParentField) and the same re-sort without blocks passes]; #12829 added the parent field and MultiSorter's read of it but did not touch SortingCodecReader, whose single-entry doc-values cache and sort-field-only assertCreatedOnlyOnce allow-list are older (LUCENE-9539, 17c285d6174 on main and 427e11c7f64 on branch_8x, 2020-09-23, first released in 8.7.0) and were never extended to the parent field [read]; so the bug is the interaction and exists from the first commit at which it can be expressed (2024-01-11 (#12829 merged into main as df6bd25ce44; the branch_9x commit 0aa88910ca9 was committed 2024-01-17, same author date); 9.10.0 tagged 2024-02-20)
First affected version
9.10.0 [exec]
Affected versions
all 19 releases from 9.10.0 through 10.5.2 (9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2) and main ea57360912ed (unreleased 11.0.0), with -ea only [exec: the finder repro and both history-probe bug scenarios fail on each release while all 3 probe controls pass; also the independent reproducer's test on 9.10.0, 9.12.3, 10.5.2 and main, the intended-behaviour check on 10.5.2 and main, and the cross-model review on 9.12.3, 10.5.2 and main]; with -da the re-sorted index is correct on 9.10.0, 9.12.3, 10.5.2 and main [exec]; 9.9.2 and earlier not applicable (no IndexWriterConfig#setParentField; the repro does not compile on 9.9.2, and the re-sort without blocks passes there) [exec]; the failing call site moved but the assertion did not change: with two or more sort fields, 9.10.0-10.4.0 already fail inside MultiSorter.sort, and from 10.5.0 (#16202, e6f12f31d08, backport 5a3ec9ad9d8) the second read is always the doc-values merge [exec]; 10.6.0 (released 2026-10-29) fails with -ea (9 of 9 runs) and passes with -da [exec 2026-11-01], and the unreleased branch_10x has the same allow-list and the same parent read [read]; main 645d4fe14a20 fails with -ea (9 of 9 runs) and passes with -da [exec 2026-11-01]; not fixed upstream as of main 2f424fd428da (2026-10-30) [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 and a prior-report search; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-26, 181 read-only queries: three scripted prior-report searches (25, 77 and 17 queries over GitHub issues and PRs, commits, downstream repos, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), 40 manual lists.apache.org queries including builds@, 7 JIRA REST queries, GitHub code search in Elasticsearch, OpenSearch and Solr, a file scan of all 412 open apache/lucene PRs with the patches of the 7 that change SortingCodecReader, MultiSorter or Sorter main sources read, and the git history of assertCreatedOnlyOnce; the intended-behaviour check added 16 mailing-list queries); the assertion message appears upstream only in the code and in the commit notifications of apache/lucene-solr#1909 and #13479; related prior items, none of them this bug: #13478 (open) and #13475 (the same assertion firing under intra-merge parallelism, where concurrent norms and doc-values merges evict each other from the single-entry cache; different trigger and fix), #13479 (NPE guard in the same assertion; precedent for fixing it when it misfires), #10579 (LUCENE-9539) and apache/lucene-solr#1909 (origin of the cache and the assertion), #12829 (added the parent-field read), #16202 (moved that read), apache/solr#4644 (closed unmerged downstream re-sort that rejects nested documents)
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and passes with them off. Standalone (seed DEADBEEF, -ea) it failed in every run on main (9 of 9), 10.6.0 and 10.5.2 (9 of 9 each) and 10.4.0 (8 of 8); 10.6.0 passes with -da. On 9.10.0, 9.12.3 and 10.0.0 it fails only in some runs under the test framework's random codec (4 of 14, 5 of 9, 6 of 9) and in every run with the release's default codec pinned (6 of 6 each); it does not compile on 9.9.2. With the recommended fix it passes, together with 4 related core suites, the whole org.apache.lucene.index test package with assertions on, two parent-field suites in other modules and the core lint; the alternative fix passes the repro and the 4 suites. Upstream main 2f424fd428da (2026-10-30) has no change to the affected files since 645d4fe14a20 (read). Earlier runs: the hunt's and verifiers' tests on main ea57360912ed, and the hunt's history probe (default codec) on all 19 releases from 9.10.0 to 10.5.2.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:47-55 (class javadoc: re-sort by wrapping all readers and calling IndexWriter#addIndexes(CodecReader...) on a fresh writer; 'should only be used for merging'; @lucene.experimental), :441-446 (wrap calls Sorter.sort), :711-716 (getNumeric goes through getOrCreateDV), :864-866 (cache comment: 'we do the work twice for these fields'), :875-885 (getOrCreate: single-entry cache, 'assert assertCreatedOnlyOnce'), :890-918 (assertCreatedOnlyOnce: a second creation is allowed only for fields in metaData.sort(); message at :914)
  • lucene/core/src/java/org/apache/lucene/index/MultiSorter.java:52-63 (per reader with blocks: getNumericDocValues(fieldInfos.getParentField()) to build the parent bitset)
  • lucene/core/src/java/org/apache/lucene/codecs/DocValuesConsumer.java:204-216 (getMergedNumericDocValues asks every producer for the field again during the doc-values merge)
  • lucene/core/src/java/org/apache/lucene/index/Sorter.java:185-199 (Sorter.sort(LeafReader) keeps blocks together with the parent field; its only production caller is SortingCodecReader.wrap)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:579-589 (setParentField: required to keep a block's document order under index sorting)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3390-3392 (addIndexes(CodecReader...) turns an Error into a tragic event)
  • SECURITY.md:28, 42, 46, 50 (threat model)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:47-55, 445-446, 713-716, 793-804, 864-885, 890-918
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/Sorter.java:185-199; index/MultiSorter.java:57-63, 84; index/MergeState.java:223
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/DocValuesConsumer.java:204-216; index/IndexWriter.java:3390-3392
Who is affected
  • build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:240-248, 440-444 (at 645d4fe14a20)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:54, 458-481
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3560, 5555; index/MergeState.java:216-223; lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:841
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:47-55, 446, 864-866, 892; index/Sorter.java:185-199; index/IndexWriter.java:3158, 3245-3251; index/IndexWriterConfig.java:580-589
  • lucene/CHANGES.txt:2311-2314, 2978-2982 (at 645d4fe14a20); grep of lucene/*/src/test for SortingCodecReader and setParentField (at 645d4fe14a20)
  • a Lucene main checkout (645d4fe14a20) lucene/misc/src/test/org/apache/lucene/misc/index/TestBPIndexReorderer.java:162; lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:841

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.