- Document blocks:
addDocumentsadds children followed by their parent. With index sorting,setParentFieldmakes IndexWriter mark the last document of each block, so the block moves as one unit. - 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 toaddIndexesof a fresh writer. - 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.
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.- Index 3 documents with
setParentField("_parent"): a block of childc1and parentp1(sort=7), then a single documentx(sort=3). Re-sort bysortas the javadoc says:SortingCodecReader.wrapgivesx, c1, p1; pass it toaddIndexesof a fresh writer with that index sort and the same parent field. - You expect
addIndexesto succeed. With assertions off it does, and the result is sorted with the block intact; the same recipe without blocks also works. - Instead, with assertions on (
-ea, as in most test runs),addIndexesthrowsAssertionError: only sort fields should be cached twice but [_parent] is not a sort field, and the destination writer closes. - 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.sortto find the blocks, then the doc-values merge to copy it. - 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 experimentalSortingCodecReader, and assertions on.
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 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 fromSortingCodecReader.java:446). In the example,c1, p1, xbecomesx, c1, p1. - 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 owngetNumericDocValues(:793-804): always along[maxDoc]and aFixedBitSet(maxDoc). - 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). - 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). - Rebuild 1 of
_parent:addIndexes(CodecReader...)merges the wrapped reader. The merge state callsMultiSorter.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 holdssort. (This is the order since 10.5.0; older releases read the sort field first.) - Rebuild 2 of
_parent: the doc-values merge asks for every numeric field again (DocValuesConsumer.java:204-216,getNumericat:215)._parentis not cached and is not a sort field, so the assertion fails.AssertionErroris anError, soaddIndexestreats it as a tragic event and the destination writer closes (IndexWriter.java:3390-3392). - With assertions off step 4 does not run:
_parentis 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
SortingCodecReaderbuild for block indexes under-eabecause of this bug. - Opt-in throughout: index sorting,
setParentField, document blocks, the@lucene.experimentalSortingCodecReaderand 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).BPIndexReordereralso wraps withSortingCodecReader.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 sortMultiSorternever 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
-eawould lose the destination writer. With assertions off the parent field is rebuilt one extra time per wrapped reader; that cost was not measured.
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.
/*
* 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));
}
}
}
}
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
addIndexesre-sorts the index tox, c1, p1without error, as it does with assertions off.- Observed
- With assertions on,
addIndexesthrowsAssertionError: only sort fields should be cached twice but [_parent] is not a sort fieldand the destination writer closes.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on, passes with them off; with either fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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)- The
SortingCodecReaderjavadoc 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 isSortingCodecReader.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).addIndexesthrows "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 isSortingCodecReader.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-2314has the same sentence).setParentFieldsays 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), andMultiSorterreads the parent field for exactly that purpose. The allow-list (LUCENE-9539, 2020) predates the parent field; #12829's review never mentionsSortingCodecReader. - No test covers this trigger: no upstream test re-sorts a block index through
SortingCodecReader.wrap(reader, sort)andaddIndexesinto an index-sorted writer.TestBPIndexReorderersets a parent field (:162) and reachesSortingCodecReader.wrap(reader, docMap, null)throughBPIndexReorderer, but with no index sort, soMultiSorternever 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.
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.
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,MultiSorterreads 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,TestAddIndexesandTestFilterReadersForwardVectorPrefetchpass with assertions on (5 suites, 109 tests, 0 failures); the repro passes with assertions off; the wholeorg.apache.lucene.indextest package passes with assertions on (1396 tests, 0 failures, 56 skipped);TestBPIndexReorderer(8 tests) andTestIndexSortBackwardsCompatibility(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,forbiddenApisMainandforbiddenApisTestpass 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 mainea57360912ed.
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]andFixedBitSet(maxDoc)stay referenced by the wrapped reader fromMultiSorter.sortuntil 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,TestAddIndexesandTestFilterReadersForwardVectorPrefetchpass with assertions on (5 suites, 109 tests, 0 failures); the repro passes with assertions off;checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainpass. - Only the repro exercises the new branch; the larger runs of A were not repeated for B.
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.
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, throughSortingCodecReader.wrapof every leaf plusaddIndexes, and in pre-sorted order, compared per stored id. - In the runs with assertions on, every iteration with document blocks failed the
SortingCodecReader+addIndexesbuild with thisAssertionError. 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.
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).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | three 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 assertCreatedOnlyOnce | related only: #13478, #13475, #13479, #10579, #12829, #16202 |
| Open PRs | 2026-10-26 | the files of all 412 open PRs; the 7 that change SortingCodecReader, MultiSorter or Sorter read in full | none changes the assertion, the cache or the double read |
| Mailing lists, JIRA archive, downstream code, web | 2026-10-26 | 40 lists.apache.org queries (dev@, java-user@, issues@, commits@, builds@), 7 JIRA queries, GitHub code search in Elasticsearch, OpenSearch and Solr, 7 web queries | no report; no downstream use of SortingCodecReader; the message only in commit notifications |
| GitHub, refresh | 2026-11-01 | items 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 |
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).
| Version or commit | Verdict | Result |
|---|---|---|
| 9.9.2 | not affected | no setParentField; the repro does not compile; the re-sort without blocks passes |
main df6bd25ce44 / branch_9x 0aa88910ca9 (#12829) vs parents b7728c56571 / 00e2fe6cacb | commit | both commits fail; their parents cannot express the scenario |
| 9.10.0 - 9.12.3 | affected | fail 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 | affected | fail 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 | affected | the minimal test fails with -ea in every run (9 of 9), passes with -da |
main 645d4fe14a20 | affected | the 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 affected | the minimal test passes with assertions on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (randomized differential test, then JUnit) | reproduced | Its 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, high | Own 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 severity | Own 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, high | Own test fails with -ea, passes with -da; controls pass; the "deliberate tripwire" reading rated moderate and rebutted. |
| intended-behaviour devil's advocate | real, low severity | Own 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, high | Re-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 none | 54 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 validated | Standalone 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 applied | Re-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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 469 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,363 words
- 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 wholeorg.apache.lucene.indextest 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 main2f424fd428da(2026-10-30) has no change to the affected files since645d4fe14a20(read). Earlier runs: the hunt's and verifiers' tests on mainea57360912ed, and the hunt's history probe (default codec) on all 19 releases from 9.10.0 to 10.5.2.
- 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-918a 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:223a 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-481a 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-589lucene/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
- TestSortingCodecReaderParentField.java (one JUnit test, public API):
repro/LFV-112/src/org/apache/lucene/index/TestSortingCodecReaderParentField.java(57 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-112/bug.png(469 KB)
![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.](../assets/LFV-112/bug.png)