getBulkPath first fills in the labels it has cached, then copies the uncached ordinals into the front of your array (DirectoryTaxonomyReader.java:522) and sorts only that prefix (:549). The slots after the prefix keep their old values, so a cached ordinal that comes before an uncached one is overwritten. The javadoc (TaxonomyReader.java:241-242) allows only a reorder.
- A facet taxonomy gives every category an integer ordinal (0 is the root);
getPath(ordinal)returns the category's label. getBulkPath(int... ordinals)returns many labels in one call. Its javadoc says it "may change (reorder elements) its parameter" and that you should avoid reusing the parameter.DirectoryTaxonomyReaderkeeps an LRU category cache of recently looked-up labels (up to 4,000 by default):getPathadds a label on a miss,getBulkPathadds its uncached ordinals.
getBulkPath when a cached ordinal comes before an uncached one: the cached entry is overwritten and another is left twice. Select the still to open it at full size. Generated with AI, checked against the code.- You build a taxonomy with two categories,
a(ordinal 1) andb(ordinal 2), and open aDirectoryTaxonomyReaderwith default settings. Its category cache keeps the labels it looked up most recently (an LRU cache of up to 4,000 by default). - You call
getPath(2), which cachesb, and latergetBulkPath(new int[] {2, 1}). - You expect the labels
[b, a]and, since the javadoc says the method "may change (reorder elements) its parameter", an array that still holds 2 and 1 in some order, as on 9.0.0 to 10.4.0. - You get the right labels
[b, a], but the array is now[1, 1]: 2 is gone and 1 is there twice. The method copies the uncached ordinals into the front of your array and never puts the cached ones back. - Only code that reads the array after the call sees this, and the same javadoc sentence advises against that; no in-tree caller does it. Affected: 10.5.0 to 10.6.0 and main (#15711).
Application code that calls getBulkPath and reads the array again afterwards, for example to pair labels with counts kept by ordinal, silently works with a lost ordinal and a duplicate. Default reader settings suffice, but the javadoc advises against reusing the array and no in-tree caller does it. Affected: 10.5.0 to 10.6.0 and main.
Mechanism, terms and numbers533 words
TaxonomyReader.getBulkPath(int... ordinals) returns the labels of several taxonomy ordinals in one call, and its javadoc warns: "this method may change (reorder elements) its parameter, you should avoid reusing the parameter after the method is called." From 9.0.0 to 10.4.0 DirectoryTaxonomyReader.getBulkPath sorted the whole array in place, exactly as described.
Since 10.5.0 (#15711, which made the method use the category cache) it looks every ordinal up in the cache first, then copies the uncached ordinals into the front of the caller's array and sorts only that prefix. The slots after the prefix keep their old values, so a cached ordinal that comes before an uncached one is overwritten and the moved value is left twice: with ordinal 2 cached, {2, 1} becomes [1, 1] and {3, 2, 1} becomes [1, 3, 1]. The returned labels are always right.
So this is a documentation/contract defect, priority Low: only a caller that reads the array again after the call, which the javadoc already advises against, gets wrong data, and no in-tree caller does. A two-line swap in the compaction loop makes the javadoc true again without an allocation; a javadoc change is the lowest-risk alternative.
- The contract.
TaxonomyReader.java:241-242: "Note: this method may change (reorder elements) its parameter, you should avoid reusing the parameter after the method is called." The default implementation (:244-250) does not touch the array; the class is@lucene.experimental(:57). - The override's own javadoc (
DirectoryTaxonomyReader.java:499-508) says nothing about the parameter, so the note is not inherited there. It still describes a StoredFields fallback (:502-505) that the code no longer has. - Cache first.
:515getPathFromCache(ordinals)fills the labels of the cached ordinals. - Compaction into the caller's array.
:519-526: for each ordinal whose label is still missing,ordinals[uncachedLength] = ordinals[i](:522). The value atuncachedLengthis overwritten, not swapped. - All cached: early return at
:528-530, the array is untouched. - Prefix sort only.
:533-549sortsordinals[0..uncachedLength)with their original positions (.sort(0, uncachedLength),:549); the lookup (:558-578) and the cache update (:580-585) read only that prefix. Nothing restores the rest. - What stays right: the returned labels;
checkOrdinalBoundsstill runs first (:512); every write stays inside the caller's array.
| call (ordinals 1 = a, 2 = b, 3 = c) | cached | labels | array afterwards |
|---|---|---|---|
getBulkPath(2, 1) | 2 | [b, a] | [1, 1] (2 lost, 1 twice) |
getBulkPath(3, 2, 1) | 2 | [c, b, a] | [1, 3, 1] (2 lost, 1 twice) |
getBulkPath(3, 2, 1) | nothing | [c, b, a] | [1, 2, 3] (a reorder) |
getBulkPath(3, 2, 1) | all | [c, b, a] | [3, 2, 1] (untouched) |
Only "a cached ordinal before an uncached one" breaks the contents: an uncached ordinal before a cached one compacts to the identity. The study's randomized check (300 trials, random cached subsets) saw the contents change in exactly those trials, with the labels right in every trial.
Worked example, {2, 1} with 2 cached: slot 0 (ordinal 2) is cached and skipped; slot 1 (ordinal 1) is uncached and copied into slot 0, giving [1, 1]; the prefix of length 1 is already sorted.
Exposure and likelihood168 words
- Who. Code that calls
DirectoryTaxonomyReader.getBulkPath(directly or through aTaxonomyReaderreference) and reads the argument again afterwards. - Default vs opt-in. The category cache is on by default (LRU, up to 4,000 labels;
setCacheSizechanges the limit).getPathadds a label on a miss andgetBulkPathadds its uncached ordinals, so the mixed cached/uncached state is normal on a long-lived reader (or one fromopenIfChanged, which shares the caches unless the taxonomy was recreated). The call pattern that sees it goes against the javadoc's advice, in an@lucene.experimentalAPI. - In-tree callers are not affected.
TaxonomyFacets.java:320-326collects the counts before the call at:328(the 2024 #14008 fix);:406passes a freshordinals.toArray();ReindexingEnrichedDirectoryTaxonomyWriter.java:72uses only the labels; the sandboxTaxonomyOrdLabelBiMap.java:44-45andTestDirectoryTaxonomyReader.java:521clone first. - It has happened before. Until #14008 was fixed in 2024, Lucene's own
TaxonomyFacetsread the array aftergetBulkPath(then a pure sort) and reported wrong counts. - Silent. No exception; the labels are right, so nothing looks wrong unless the array is read again.
One JUnit method on the public API: two categories, getPath(2) to cache ordinal 2, then getBulkPath(new int[] {2, 1}). It checks that the array still holds 1 and 2 in some order. Single thread; its only explicit randomized call is newDirectory() (the test framework also randomizes codec, locale and the like).
/*
* 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.facet.taxonomy.directory;
import java.util.Arrays;
import org.apache.lucene.facet.taxonomy.FacetLabel;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestGetBulkPathArgumentOnlyReordered extends LuceneTestCase {
public void testArgumentIsOnlyReordered() throws Exception {
try (Directory dir = newDirectory()) {
try (DirectoryTaxonomyWriter w = new DirectoryTaxonomyWriter(dir)) {
w.addCategory(new FacetLabel("a"));
w.addCategory(new FacetLabel("b"));
}
try (DirectoryTaxonomyReader r = new DirectoryTaxonomyReader(dir)) {
r.getPath(2);
// ordinal 2 is cached: expected {2, 1} in some order afterwards, actual [1, 1]
int[] ordinals = {2, 1};
r.getBulkPath(ordinals);
int[] sorted = ordinals.clone();
Arrays.sort(sorted);
assertArrayEquals(
"javadoc: getBulkPath may only reorder its parameter [2, 1], but it now holds "
+ Arrays.toString(ordinals),
new int[] {1, 2},
sorted);
}
}
}
}
cp $REPRO/TestGetBulkPathArgumentOnlyReordered.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/taxonomy/directory/
cd $LUCENE
./gradlew :lucene:facet:test --tests org.apache.lucene.facet.taxonomy.directory.TestGetBulkPathArgumentOnlyReordered -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
- Expected
- The labels are
[b, a]and the array still holds 2 and 1, in some order, as on 9.0.0 to 10.4.0. - Observed
- The labels are
[b, a], but the array holds[1, 1](expected:<2> but was:<1>), with assertions on and off.
More files, environment, full output208 words
- Seeds and assertions. Fails with seeds
DEADBEEFand5EED1234, with assertions on and off. - Releases. The same file fails unchanged on the 10.5.0 and 10.6.0 release jars and passes on 10.4.0 and 9.12.3.
- As a regression test. With the swap fix or the clone fix the test passes unchanged, so it is the regression test. With the javadoc-only fix it becomes obsolete, because the documented contract then allows the overwrite.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on the 10.5.0 and 10.6.0 release jars; passes on 10.4.0 and 9.12.3 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp $REPRO/TestGetBulkPathArgumentOnlyReordered.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/taxonomy/directory/
cd $LUCENE
./gradlew :lucene:facet:test --tests org.apache.lucene.facet.taxonomy.directory.TestGetBulkPathArgumentOnlyReordered -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestGetBulkPathArgumentOnlyReordered > testArgumentIsOnlyReordered FAILED
javadoc: getBulkPath may only reorder its parameter [2, 1], but it now holds [1, 1]: arrays first differed at element [1]; expected:<2> but was:<1>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:AAD32356BCA40DAE]:0)
at app/junit@4.13.2/org.junit.internal.ComparisonCriteria.arrayEquals(ComparisonCriteria.java:78)
...
at app//org.apache.lucene.facet.taxonomy.directory.TestGetBulkPathArgumentOnlyReordered.testArgumentIsOnlyReordered(TestGetBulkPathArgumentOnlyReordered.java:39)
:lucene:facet:test (FAILURE): 1 test, 1 failure
# the same with -Ptests.asserts=false: the same failure
# standalone, seed DEADBEEF, assertions on
10.6.0 FAIL ... but it now holds [1, 1] (also with assertions off)
10.5.0 FAIL ... but it now holds [1, 1]
10.4.0 PASS
9.12.3 PASS- The javadoc allows only a reorder: "may change (reorder elements) its parameter" (
TaxonomyReader.java:241-242). A duplicate is not a reorder. - Lucene's own comments agree: getBulkPath "shuffles its input array" and "changes order of param's elements".
- The overwrite came with a cache speed-up (#15711) that never mentions the argument; a two-line swap keeps the speed-up and the contract.
The full argument128 words
- The note was exact when written. #12769 (
fb5f491643b, 2023-11-09, first in 9.9.0 and 10.0.0) added it when the method sorted the whole array in place; the same PR added the test comment "clone because getBulkPath changes order of param's elements". - The change was a side effect. #15711 ("let getBulkPath use cache", fixes #15705) makes the method use the cache result it used to compute and throw away. Its description, reviews and linked issue never mention the argument; it did not touch
TaxonomyReader.java;CHANGES.txt:990lists it under Improvements as "DirectoryTaxonomyReader.getBulkPath() now uses getPathFromCache";MIGRATE.mdsays nothing about getBulkPath. - The overwrite is not needed for correctness. Swapping instead of copying keeps the array a permutation and the speed-up, with no allocation (Suggested fix); no benchmark was run.
The devil's-advocate check197 words
The strongest case for "leave it": the same sentence says "you should avoid reusing the parameter after the method is called", so a caller that follows it is never affected. In #14008 (2024) a contributor wrote "We're using our API incorrectly by referencing ordinals after getBulkPath", and the fix went into the caller. Both classes are @lucene.experimental, overwriting saves work, and a maintainer can read "(reorder elements)" as an example rather than a limit.
Why it fails: it answers a wrong-results framing, which this report does not make. It does not make "(reorder elements)" accurate, and Lucene's own comments ("shuffles", "changes order") read the note as a permutation; the override's own javadoc says nothing about the parameter at all. Every verifier and reviewer in the study agreed on the mechanism, but the contract question stayed open: the independent reviewer from another model family (OpenAI GPT-5.6), refute lens, rated the "intended" case strong, called the contract question "unresolved" and wrote "A maintainer may still want to clarify that wording even if arbitrary mutation is accepted". Confidence is high that the note is inaccurate, and low to medium that a maintainer will think it worth a change.
Recommended A. Swap instead of overwrite (recommended)
Swap instead of overwrite in the compaction loop of DirectoryTaxonomyReader.getBulkPath (+3 lines): your array stays a reordering of itself, as the javadoc says, with no allocation. Validated with gradle: the minimal test passes with assertions on and off, and the facet module passes (307 tests). Alternative: document that the array's contents are unspecified after the call.
for (int i = 0; i < ordinalsLength; i++) {
if (bulkPath[i] == null) {
+ // swap, do not overwrite: the caller's array may only be reordered
+ int x = ordinals[uncachedLength];
ordinals[uncachedLength] = ordinals[i];
+ ordinals[i] = x;
originalPosition[uncachedLength] = i;
uncachedLength++;
}All options and trade-offs3 options ยท 739 words
Three options; all were applied in a scratch worktree of main 645d4fe14a20 and run with gradle. The swap is suggested because it makes the existing javadoc true again without an allocation (not benchmarked); the javadoc fix is the lowest-risk alternative for a maintainer who prefers no code change. The swap is this brief's suggestion; the clone is the study's.
In the compaction loop of DirectoryTaxonomyReader.getBulkPath, swap the uncached ordinal into the prefix instead of copying it. Before step i, the prefix holds the uncached ordinals seen so far, the slots up to i hold cached ones, and slot i still holds the caller's value; the swap keeps that, so the array stays a permutation, and the later code reads only the prefix.
for (int i = 0; i < ordinalsLength; i++) {
if (bulkPath[i] == null) {
+ // swap, do not overwrite: the caller's array may only be reordered
+ int x = ordinals[uncachedLength];
ordinals[uncachedLength] = ordinals[i];
+ ordinals[i] = x;
originalPosition[uncachedLength] = i;
uncachedLength++;
}Trade-offs
- Makes the current javadoc true again and keeps #15711's speed-up; no allocation, one extra array read and one extra array write per uncached ordinal (not benchmarked).
- Callers still get a reordered array, as documented, but not the one 10.5.0 to 10.6.0 leave:
{2, 1}comes back[1, 2](executed); the labels do not change. - A suggestion, not a must: the javadoc fix is the lowest-risk change for an
@lucene.experimentalAPI, and a maintainer may prefer it.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20(2026-11-01): the minimal test passes with seedDEADBEEF, assertions on and off, and with seed5EED1234. - The whole facet module (
:lucene:facet:test, the minimal test included) passes: 307 tests, 7 skipped, assertions on. :lucene:facet:checkGoogleJavaFormatand:lucene:facet:ecjLintMainpass (eclint is not configured in that build, so no eclint checks ran). Not run:gradlew check, other modules, any benchmark.- A public-API probe with the swap on main (assertions on and off):
{2, 1}with 2 cached comes back[1, 2],{3, 2, 1}[1, 3, 2]; labels right.
Say that the parameter's contents are unspecified after the call, and repeat the note on the DirectoryTaxonomyReader override, whose own javadoc does not inherit it.
--- TaxonomyReader.java
- * <p>Note: this method may change (reorder elements) its parameter, you should avoid reusing the
- * parameter after the method is called.
+ * <p>Note: this method may change its parameter (reorder or overwrite its elements), so its
+ * contents are unspecified after the call; you should avoid reusing the parameter after the
+ * method is called.
--- DirectoryTaxonomyReader.java (getBulkPath javadoc, before @param)
+ * <p>Note: this method may reorder and overwrite elements of its parameter, so its contents are
+ * unspecified after the call; you should avoid reusing the parameter after the method is called.
+ *Trade-offs
- No behaviour change; documents what 10.5.0 to 10.6.0 already do.
- Concedes a weaker contract: a future caller that ignores the note gets a duplicate, not just a different order. The minimal test becomes obsolete.
- Optionally drop the stale StoredFields sentence of the override's javadoc (
DirectoryTaxonomyReader.java:502-505) in the same change (not in the diff).
What the checks validated
- Gradle, same scratch worktree:
:lucene:facet:checkGoogleJavaFormatand:lucene:facet:renderJavadocpass, and the renderedTaxonomyReaderandDirectoryTaxonomyReaderpages show the new notes. - No test was re-run: the change is inside comments, so it would not change the current test outcome; the minimal test becomes obsolete.
Rename the parameter and work on ordinalsParam.clone(), so the caller's array is never touched.
- public FacetLabel[] getBulkPath(int... ordinals) throws IOException {
+ public FacetLabel[] getBulkPath(int... ordinalsParam) throws IOException {
ensureOpen();
- checkOrdinalBounds(ordinals);
+ checkOrdinalBounds(ordinalsParam);
+ // work on a copy: the uncached ordinals are compacted and sorted below
+ final int[] ordinals = ordinalsParam.clone();Trade-offs
Stronger than the contract (the array is untouched) at one int[] copy per call; the method already allocates an int[] and a FacetLabel[] of the same length.
What the checks validated
- Gradle, same scratch worktree: the minimal test passes (seed
DEADBEEF, assertions on) and:lucene:facet:checkGoogleJavaFormatpasses. - Caution: the study's version of this diff renamed the parameter but not its
@paramtag and does not compile in the gradle build (doclint: "@param name not found"); the diff used here also renames the tag.
Downstream code that reuses the ordinals array after getBulkPath, for example to pair labels with per-ordinal counts, should copy it first; the returned labels are unaffected.
Found by reading DirectoryTaxonomyReader.getBulkPath while writing concurrency tests for the taxonomy reader's cache: the test harness had to pass a copy of the array. It was then confirmed with a sequential public-API JUnit on main and on release jars. Not found by a formal model.
The method in full115 words
- Context. A hunt unit wrote controlled-concurrency (Fray) and linearizability (Lincheck) harnesses for taxonomy reader cache sharing and commit discovery. Those runs found nothing; while writing them, the harness had to pass a clone to
getBulkPath, which led to reading the compaction loop (2026-10-27). - Witness. A sequential public-API JUnit on the stock main jar: three categories,
getPath(2), thengetBulkPath(3, 2, 1); the array came back[1, 3, 1], with assertions on and off. It was later reduced to two categories and{2, 1}. - Then an independent reproducer, read-only cross-model reviews (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication, a release history of 40 runs, a prior-art search and a credibility audit.
A best-effort search found no prior report: 51 queries over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, four mailing lists, the JIRA archive and the web (2026-10-27), refreshed 2026-11-01. Closest: #14008 (2024), a caller that read the array after a pure sort, fixed in the caller.
Searches3 searches
Read, not the same bug: #14008 / #14010 (2024: TaxonomyFacets read the array after a pure sort, a permitted reorder; fixed in the caller); #15711 / #15705 (introduced the compaction; nobody discusses the argument); #12769 (wrote the note when it was true); #179, #10515 (LUCENE-9476) and apache/lucene-solr#2247 (the original method; #2247 discusses duplicate ordinals in the input); #15803 and #16783 (other getBulkPath or cache changes). Gaps: GitHub keyword search reads only the first page per query; list and JIRA search match words, not phrases.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, incl. the migrated JIRA archive) | 2026-10-27 and 2026-11-01 | a scripted prior-report search, 51 queries (16 issue/PR, 3 commit-message, 4 JIRA, 6 web, 2 downstream, 20 mailing-list; 154 distinct hits) plus the finder's run (40 queries); the exact phrases "reorder elements" and "avoid reusing the parameter"; on 2026-11-01 getBulkPath issues and PRs updated since 2026-10-27 | no report; "reorder elements" 0 hits; nothing updated since 2026-10-27 |
| Open PRs | 2026-10-27 | the file lists of all 408 open apache/lucene PRs | four touch the two files; none changes the compaction or the note |
| Mailing lists and downstream projects | 2026-10-27 | dev@, java-user@, issues@, commits@; Elasticsearch, OpenSearch, Solr | no report; getBulkPath gets 0 hits downstream |
Introduced by #15711 (9ece7fd321ab, merged 2026-03-27; branch_10x d3118e0abf4), first released in 10.5.0. From 9.0.0 to 10.4.0 the method sorted the whole array, a pure reorder. Affects 10.5.0 to 10.6.0 and main; not fixed upstream.
Versions tested5 rows
- API:
getBulkPathcame with LUCENE-9476 (#179,1036c708db7, 2021, first in 9.0.0) and sorted the whole array in place. - Note: #12769 (
fb5f491643b, 2023-11-09, first in 9.9.0 and 10.0.0) added "may change (reorder elements) its parameter", exact at the time. - Regression: #15711 by shubhamsrkdev (fixes #15705; approved by epotyom and msokolov, merged by msokolov 2026-03-27 as
9ece7fd321ab); branch_10x cherry-pickd3118e0abf4(2026-03-31), in the 10.5.0, 10.5.1, 10.5.2 and 10.6.0 tags.git blameattributes the cache lookup, the compaction loop and the early return to it. - Bisected (the study's 3-category test): only
DirectoryTaxonomyReader.javafromd3118e0abf4in the 10.4.0 jars brings the failure in; the file fromd3118e0abf4^in the 10.5.0 jars removes it; reverting the #15711 hunks on main makes the test pass. - Upstream: on main
f815e679bf6c(2026-11-01) the last commit onDirectoryTaxonomyReader.javais still #16783 (2026-10-12,getBulkOrdinalsonly) and onTaxonomyReader.java#15134 (2025-10-09).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails (gradle and standalone, assertions on and off, two seeds); passes with the swap or the clone fix |
| 10.6.0 | affected | minimal test fails, assertions on and off: [1, 1] |
| 10.5.0, 10.5.1, 10.5.2 | first: 10.5.0 | minimal test fails on 10.5.0; the 3-category test fails on all three: [1, 3, 1] |
| 10.4.0 | last before #15711 | minimal test and the 3-category test pass (whole-array sort) |
| 9.0.0 to 9.12.3, 10.0.0 to 10.3.2 | not affected | the 3-category test passes on all of them; the minimal test passes on 9.12.3 |
Verifiers, an independent reviewer from another model family (OpenAI GPT-5.6, read-only) and an adjudication agree: real, as a documentation/contract defect; the labels are always right. A later triage judged it defensible as documented, because the javadoc already advises against reusing the array.
Checks and verdicts341 words
The study ran on main a9779ef3c193; DirectoryTaxonomyReader.getBulkPath and the TaxonomyReader note are the same at 645d4fe14a20 (same line numbers). No cross-model reviewer could execute code.
| Check | Verdict | What it found |
|---|---|---|
| finder (a hunt unit) | reproduced, documentation defect | Public-API JUnit fails on main (assertions on and off) and 10.5.0, passes on 10.4.0 and with the clone fix. |
| cross-model review, refute lens | not real, procedural only | Read-only, so it ran no code; agreed on the root cause, rated the intended case strong, called the contract question "unresolved" and wrote "A maintainer may still want to clarify that wording even if arbitrary mutation is accepted". |
| independent reproducer | real, doc | Own JUnit fails 3 of 6 on main, 10.5.2 and 10.5.0, passes 6 of 6 on 10.4.0 and 9.12.3; randomized: 202 of 300 changed, 0 mismatches, labels always right. |
| cross-model review, intended lens | real, medium | Intended case strong but not conclusive; a separate trigger test fails on main and 10.5.2 and passes on 10.4.0. |
| devil's advocate | real, documentation defect only | Built the works-as-designed case; it answers a wrong-results framing, not the inaccurate note. |
| adjudication | confirmed, high, documentation defect | Re-ran the independent reproduction's JUnit (3 of 6 fail, 192 of 300 changed) and the finder's JUnit. |
| credibility audit | confirmed, minor | Re-ran the finder's JUnit with assertions on and off: [1, 3, 1]. |
| later triage (2026-10-29) | defensible as documented | The same sentence advises against reusing the parameter and the labels are always right; the finding stays confirmed. |
| this brief (2026-11-01) | fails on stock main; fixes validated | Gradle in a scratch worktree: the minimal test with options A, B and C; standalone runs on 9.12.3, 10.4.0, 10.5.0 and 10.6.0. |
| fact checks (2026-11-01) | real; wording fixed | An independent fact check re-ran the test (fails on main, 10.5.0, 10.5.2 and 10.6.0; passes on 10.4.0 and 9.12.3) and a labels probe (labels [b, a], the table's arrays). It and a cross-model fact check (OpenAI GPT-5.6, read-only) found wording errors, fixed in this version. |
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 399 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,287 words
- Registry title
- DirectoryTaxonomyReader.getBulkPath overwrites and duplicates entries of its ordinals argument when a cached ordinal comes before an uncached one (since 10.5.0), although the TaxonomyReader javadoc says the method may only reorder them (documentation/contract defect; the returned labels are correct)
- Area
- facet / taxonomy (DirectoryTaxonomyReader.getBulkPath: side effect on the ordinals argument of the category-cache path added by #15711)
- Priority
- Low, rank 1260: This is a documentation defect in the experimental getBulkPath (released since 10.5): the labels it returns are correct and it only misleads callers who reuse the array after the call.
- Impact class
- minor
- Impact note
- Documentation/contract only: the returned labels are always correct, but after any DirectoryTaxonomyReader.getBulkPath call in which an uncached ordinal comes after a cached one (the cache is filled by earlier getPath or getBulkPath calls on the same reader, or shared after a reopen; default settings) the caller's int[] holds a duplicate in place of one of its entries ([3, 2, 1] becomes [1, 3, 1] with ordinal 2 cached), so only application code that calls the @lucene.experimental getBulkPath directly and reads the array again afterwards (for example to sum per-ordinal counts), which the javadoc already advises against, gets wrong numbers; no in-tree caller does this; releases 10.5.0 to 10.6.0 and main.
- Summary
- You pass an array of taxonomy ordinals to DirectoryTaxonomyReader.getBulkPath, whose inherited TaxonomyReader javadoc says the method 'may change (reorder elements) its parameter', so you expect the array to hold the same ordinals afterwards, perhaps in another order, as it does in 9.0.0 to 10.4.0. Since 10.5.0 (#15711, which made getBulkPath use the category cache) the method packs the uncached ordinals into the front of your array and sorts only that prefix, so a cached ordinal that comes before an uncached one is overwritten and another entry is duplicated. With ordinal 2 cached, getBulkPath(new int[] {3, 2, 1}) returns the right labels [c, b, a] but leaves the array as [1, 3, 1].
- Introduced
- 9ece7fd321ab118773ac74ea4375690dbe3beca3 on main ('Update DirectoryTaxonomyReader.java to let getBulkPath use cache (#15711)', by shubhamsrkdev, fixes #15705, approved by epotyom and msokolov, merged 2026-03-27 by msokolov) and its branch_10x cherry-pick d3118e0abf4 (2026-03-31, first released in 10.5.0) [read: git blame on main a9779ef3c193 attributes DirectoryTaxonomyReader.java:515, :519-526 (the compaction loop) and :528-530 (the early return) to it; exec: shadowing only DirectoryTaxonomyReader.java from d3118e0abf4 into the 10.4.0 jars brings the failure in, the file from d3118e0abf4^ in the 10.5.0 jars removes it, and reverse-applying the #15711 hunks on main makes the repro pass]. The javadoc note it made inaccurate came from #12769 (fb5f491643b, epotyom, 2023-11-09; first in 9.9.0 and 10.0.0), written when the method only sorted the whole array in place (LUCENE-9476 / #179, 1036c708db7, 2021). (2026-03-27 (main 9ece7fd321a); 2026-03-31 (branch_10x d3118e0abf4))
- First affected version
- 10.5.0 [exec] (10.4.0, the last release before #15711, is not affected [exec]; the API exists since 9.0.0)
- Affected versions
- 10.5.0, 10.5.1, 10.5.2 and main a9779ef3c193 [exec: the finder's JUnit fails on all of them, main with -ea and -da (follow-up, 40 runs); the independent reproduction's JUnit fails 3 of 6 on main (-ea, -da), 10.5.2 and 10.5.0; a public-API main() probe shows [1, 3, 1] on 10.5.0, 10.5.2 and main]. Not affected [exec]: all 30 releases from 9.0.0 to 10.4.0, including 9.12.3 (latest 9.x), where the whole array is sorted in place, a permutation; getBulkPath does not exist before 9.0.0 (8.11.4 jar). No 9.x release has the change. Not fixed upstream: upstream main 20ced71c1e6e (2026-10-27), branch_10x and branch_10_6 still contain the compaction and the unchanged 'reorder elements' note [read], so 10.6.0 (4ae946e5a9b) is affected too [exec 2026-11-01: the minimal test fails on the 10.6.0 release jars with assertions on and off, the array left as [1, 1]]. All runs on aarch64 Linux with JDK 25.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (searched 2026-10-27): a scripted search with 51 queries (16 GitHub issue/PR queries on apache/lucene, 3 commit-message, 2 downstream over Elasticsearch, OpenSearch and Solr, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive, 6 web; 154 distinct hits) plus the finder's earlier run (40 queries, 52 hits); manual GitHub searches (the exact phrases 'reorder elements' and 'avoid reusing the parameter' get 0 hits on apache/lucene; 'getBulkPath' gets 0 on elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr); the file lists of all 408 open apache/lucene PRs (none changes the compaction or the note); and the discussions of #15705, #15711, #14008, #14010, #12769, #179, #10515 and apache/lucene-solr#2247 read in full. Related, not this bug: #15711 / #15705 (introduced the compaction; nobody discusses the argument), #14008 (2024, fixed in the caller by #14010: TaxonomyFacets read the array after getBulkPath had only sorted it, a permitted reorder, and a contributor called post-call reuse API misuse), #12769 (wrote the 'reorder elements' note when it was true). Not fixed upstream: main 20ced71c1e6e, branch_10x and branch_10_6.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28), through gradle in a scratch worktree (2026-11-01): the minimal test fails on stock main with assertions on and off; with the swap fix it passes (assertions on and off, two seeds) and the facet module's tests pass; with the clone fix it passes; the javadoc fix passes the format check and the javadoc render. Standalone, the same test fails on the 10.5.0 and 10.6.0 release jars and passes on 10.4.0 and 9.12.3. Upstream mainf815e679bf6c(2026-11-01) has the same two files.
- Finding record
lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyReader.java:241-242 (the 'may change (reorder elements) its parameter, you should avoid reusing the parameter' note), :57 (@lucene.experimental), :244-250 (the default implementation, which does not touch the array)lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java:499-508 (override javadoc: nothing about the parameter; still describes the StoredFields fallback that #15711 removed), :510-549 (getBulkPath: :515 getPathFromCache, :519-526 compaction with :522 'ordinals[uncachedLength] = ordinals[i]', :528-530 early return, :549 sort(0, uncachedLength)), :61 (@lucene.experimental)lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:318-328 (counts collected before getBulkPath, the #14008 fix) and :406 (passes ordinals.toArray()); lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java:72 (uses only the labels); lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/labels/TaxonomyOrdLabelBiMap.java:44-45 ('Have to clone because getBulkPath shuffles its input array'); lucene/facet/src/test/org/apache/lucene/facet/taxonomy/directory/TestDirectoryTaxonomyReader.java:521 ('clone because getBulkPath changes order of param's elements'); lucene/CHANGES.txt:973 (GITHUB#15711 under 10.5.0)
- The finding in 30 seconds
lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyReader.java:57, 234-250 (at 645d4fe14a20)lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java:65, 135-136, 465-475, 499-508, 510-549, 558-585 (at 645d4fe14a20)
- Who is affected
lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:320-328, 406 (at 645d4fe14a20)lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java:72 (at 645d4fe14a20)lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/labels/TaxonomyOrdLabelBiMap.java:44-45; lucene/facet/src/test/org/apache/lucene/facet/taxonomy/directory/TestDirectoryTaxonomyReader.java:521 (at 645d4fe14a20)lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java:65, 135-136, 211-217, 458-460, 580-585, 645 (at 645d4fe14a20) (default LRU cache size; caches shared on reopen unless recreated; getPath and getBulkPath fill it; setCacheSize)
- Why it looks unintended
lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyReader.java:241-242 (at 645d4fe14a20)lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/labels/TaxonomyOrdLabelBiMap.java:45; lucene/facet/src/test/org/apache/lucene/facet/taxonomy/directory/TestDirectoryTaxonomyReader.java:521 (at 645d4fe14a20)lucene/CHANGES.txt:990 (at 645d4fe14a20)
- Suggested fix
lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java:519-526, 549, 558-585 (at 645d4fe14a20)
- History
upstream GitHub API (read-only, 2026-11-01): commits touching DirectoryTaxonomyReader.java and TaxonomyReader.java
- Diagrams
lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java:515, 519-530, 549, 558-576 (at 645d4fe14a20)
- TestGetBulkPathArgumentOnlyReordered.java:
repro/LFV-797/src/org/apache/lucene/facet/taxonomy/directory/TestGetBulkPathArgumentOnlyReordered.java(47 lines)
A summary card (the PNG): the user caches ordinal 2 and calls getBulkPath(new int[] {2, 1}); the cache lookup; the compaction loop copies 1 over the 2; only the prefix is sorted; the labels [b, a] are right but the array is [1, 1]; which calls change the array; the swap fix and the javadoc alternative. Every value on screen comes from an executed public-API probe (main with assertions on and off, 10.4.0, 10.6.0, and main with the swap); every code row is checked against the source. Notes next to the files.
assets/LFV-797/bug.png(399 KB)
![Diagram of the bug: DirectoryTaxonomyReader.getBulkPath copies the uncached ordinals into the front of the caller's array and sorts only that prefix, so with a cached ordinal before an uncached one the array loses an entry and holds a duplicate (for example {2, 1} with ordinal 2 cached becomes [1, 1]), while the returned labels stay right.](../assets/LFV-797/bug.png)