All findings · Lucene finding · LFV-363 · taxonomy-gettopdims-stale-dimord

Taxonomy getTopDims shows another dimension's children under a multi-valued dimension's name

With multi-valued taxonomy dimensions, Facets#getTopDims can return the right dimension name with another dimension's children and childCount (and, with requireDimCount, its value): x with 2 hits comes back as x value=1 [y1 (1)], which are y's numbers. A reused queue entry keeps the old dimension's ordinal; every release since 9.2.0 does this.

Summary

Priority
Medium, rank 176GetTopDims can silently give one dimension's entry another dimension's children, which is plausible, but it needs multi-valued dims with requireDimCount, a narrower configuration than its sibling LFV-368.
Impact
silent wrong results
Since
9.2.0 (#779 and #806, backported in #889, 2022)introduced 2022-05-13 · about 4 years
Affected
9.2.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
bounded-exhaustive check of the facet API, confirmed by JUnit

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Taxonomy facets can list the top dimensions of a result set with Facets#getTopDims(topNDims, topNChildren); its javadoc says the result equals the first topNDims entries of getAllDims. Dimensions whose documents can carry several values, such as tags or authors, must be declared with FacetsConfig#setMultiValued. The bug hits those.

Summary card. Title: Taxonomy getTopDims: a multi-valued dim's entry can carry another dim's children. A reused queue entry gets the winner's dim and value but keeps the evicted dim's dimOrd. Inside: visit y (ordinal 4), the queue holds dim y, dimOrd 4, value 1; visit x (ordinal 1), 2 > 1, the entry is reused as dim x, dimOrd 4, value 2; the result, built from ordinal 4, is dim=x value=1 childCount=1 y1 (1); x's value 2 and its children x1, x2 are silently missing. Expected: dim=x value=2 childCount=2 x1 (1) x2 (1), equal to getAllDims(10).subList(0, 1). Actual: dim=x value=1 childCount=1 y1 (1). The one-line fix sets bottomDim.dimOrd = dimOrd.
The x/y example step by step: counting gives x 2 and y 1; getTopDims visits the newest dimension y first and puts it in the one-entry queue; x beats it and the entry is reused, keeping y's ordinal 4; the result is built from ordinal 4, y's child under x's name. Expected against actual on main. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Declare dimensions x and y multi-valued with requireDimCount (as the getTopDims javadoc advises) and index three documents: x=x1, x=x2, then y=y1. So x is created first.
  2. Run a match-all query and call getTopDims(1, 10) on FastTaxonomyFacetCounts. You expect what getAllDims(10) lists first: x, value 2, children x1 (1) and x2 (1).
  3. You get x with value 1, childCount 1 and the child y1 (1): y's numbers under x's name, and x's own children are gone. No error, with assertions on or off.
  4. Why: dimensions are visited newest first, so y fills the one-slot queue. When x beats it, Lucene reuses the slot and sets its name and value but not its ordinal, and the children are later read from y's ordinal.
  5. The order breaks too: getTopDims(2, 10) over a (3 hits), b (2) and c (1) returns a with value 1 and c's child above b with value 2. Adding the missing bottomDim.dimOrd = dimOrd; fixes it, and the whole facet test suite passes.

Who is affected

Applications that call getTopDims on FastTaxonomyFacetCounts or the int and float association facets with multi-valued dimensions, asking for fewer dimensions than the code admits (every dimension with a non-zero value). Deterministic: an older multi-valued dimension that outranks a newer one in the full queue gets the newer one's children. Single-valued dimensions, getAllDims, getTopChildren and SortedSetDocValues facets are correct.

Mechanism, terms and numbers791 words

Facets#getTopDims(topNDims, topNChildren) should return the same as the first topNDims entries of getAllDims. Taxonomy facets compute it with a priority queue of topNDims entries, each holding a dimension's name, ordinal and value. When the queue is full and a dimension beats the bottom entry, TaxonomyFacets#getTopDims reuses that entry and updates its name and value but not its ordinal. A multi-valued dimension's children are computed from that ordinal at the end, so the result carries the pushed-out dimension's children and childCount under the winner's name (with requireDimCount, since 9.3.0, also its value), and the list can fall out of value order. Dimensions are visited newest-created first, so it happens whenever an older multi-valued dimension outranks a newer one already in the queue; with topNDims = 1 a multi-valued winner gets the children of the newest-created dimension with a non-zero value (with requireDimCount, the newest-created one with hits). In the repro, x with 2 hits is returned as x value=1 childCount=1 [y1 (1)]. It needs FacetsConfig#setMultiValued, which multi-valued data requires, and fails with and without requireDimCount. Single-valued dimensions, getAllDims, getTopChildren, hits and SortedSetDocValues facets are correct. Every release from 9.2.0 to 10.6.0 (10.6.0 by reading) and main is affected; one added line fixes it.

Terms

  • Taxonomy facets: each document gets category labels such as x/x1 through FacetField; a side index, the taxonomy, gives each dimension (x) and each label (x/x1) an ordinal, in order of creation. FastTaxonomyFacetCounts counts a query's hits per ordinal; the association facets aggregate a value instead.
  • Multi-valued dimension: FacetsConfig.setMultiValued(dim, true), "Pass true if this dimension may have more than one value per document" (FacetsConfig.java:180-181). Such data needs it: FacetsConfig.build rejects a second value of a single-valued dimension ("is not multiValued, but it appears more than once in this document", FacetsConfig.java:233-241). With setRequireDimCount(dim, true) the dimension's own ordinal is indexed too, so its document count is exact (FacetsConfig.java:190-194, :386-397).
  • getTopDims: "Returns labels for topN dimensions and their topNChildren sorted by the number of hits/aggregated values that dimension matched. Results should be the same as calling getAllDims and then only using the first topNDims" (Facets.java:60-62). Taxonomy facets override it with a priority queue of topNDims entries; each DimValue holds dim, dimOrd and value (TaxonomyFacets.java:46-56, :610-614).

The bug

  1. The loop visits dimensions newest-created first: the taxonomy's children array gives "the category that was added last" and each sibling is "the previous youngest child" (TaxonomyIndexArrays.java:253-255, :269; the loop is TaxonomyFacets.java:620-668).
  2. While the queue has room, each dimension with a non-zero value gets a new entry (:652-654). Once it is full, a dimension that beats the bottom entry reuses it: bottomDim.dim = dim; bottomDim.value = dimValue; pq.updateTop(); (:656-662). The entry's dimOrd keeps the ordinal of the dimension that was pushed out.
  3. At the end, a single-valued dimension takes its children from a cache keyed by name (:639-651, :678-680): correct. A multi-valued dimension's children are computed from dimValue.dimOrd (:683), and for multi-valued dimensions with requireDimCount the value comes from that ordinal too (without it, the -1 placeholder; :506-512). createFacetResult(topChildrenForPath, dim) labels the result with the winner's name (:685).
  4. No entry is created after the queue is full, so the ordinals in the queue stay those of the first topNDims dimensions admitted: the newest-created ones with a non-zero value (:652). That includes multi-valued dimensions without requireDimCount, admitted at -1 even without hits (the sibling bug LFV-368). With topNDims = 1 a multi-valued winner therefore gets the children of the newest-created dimension with a non-zero value; with requireDimCount on every dimension, of the newest-created dimension with hits. A single-valued winner keeps its own children, cached by name.

The numbers

  • x/y: taxonomy ordinals 1 = x, 4 = y. Visit y: the queue gets {y, 4, 1}. Visit x: 2 > 1, the entry becomes {x, 4, 2}. The result is built from ordinal 4: x value=1 childCount=1 [y1 (1)] instead of x value=2 childCount=2 [x1 (1), x2 (1)].
  • a, b, c with 3, 2 and 1 hits, created in that order, getTopDims(2, 10): [a value=1 childCount=1 [c1 (1)], b value=2 childCount=2 [b1 (1), b2 (1)]], out of value order.
  • Variants the verifiers executed: a name tie between dimensions without dim counts (value -1 each); TaxonomyFacetIntAssociations (dimension a reported with b1 (7)); a multi-valued dimension replacing a single-valued one (value=0 and the loser's child); and, when the pushed-out dimension has no hits (possible only through the sibling bug LFV-368), a null entry with assertions off or an AssertionError at TaxonomyFacets.java:686 with assertions on.
Exposure and likelihood385 words
  • Who is exposed. Applications that call getTopDims on FastTaxonomyFacetCounts, TaxonomyFacetIntAssociations or TaxonomyFacetFloatAssociations (all three executed; the float class with a value source and with indexed associations) with dimensions declared multi-valued, and ask for fewer dimensions than the code admits: every dimension with a non-zero value (TaxonomyFacets.java:652), which includes multi-valued dimensions without requireDimCount even when they have no hits (:637, the sibling LFV-368). The exact trigger is a multi-valued winner replacing the bottom entry of a full queue. The association facets cannot follow the javadoc's requireDimCount advice (FacetsConfig.build rejects it, FacetsConfig.java:305-307), so for them the value stays the -1 placeholder while the children and childCount are wrong. Tags, authors or genres must be multi-valued, because FacetsConfig.build rejects a second value otherwise; the demo's association dimensions are multi-valued (AssociationsFacetsExample.java:59-61), though the demo does not call getTopDims.
  • Default vs opt-in. setMultiValued is opt-in (the default dimension config is single-valued) but required for multi-valued data. requireDimCount, which the javadoc recommends for getTopDims, does not help. The defect is in query-time code that runs after counting, so codec, IndexWriterConfig and searcher should not matter; the test (randomized directory, searcher and codec, plain IndexWriterConfig) failed with every seed tried: the fixed seeds and 10 random iterations, on Linux aarch64.
  • Scale. Deterministic for a fixed taxonomy, counts and query; one document with three dimensions is enough. A multi-valued entry is right only when no older multi-valued dimension had to push a newer one out of the full queue; with topNDims = 1 and requireDimCount on every dimension, only when the top dimension is also the newest-created one with hits.
  • Silent. The wrong entry has the right name and plausible numbers, and nothing is thrown (except in LFV-368's null-entry case). FacetResult#equals compares the value, the childCount and every child label and count but ignores dim and path (FacetResult.java:72-79), so a comparison with equals misses it only when all of those happen to match.
  • Not affected. Single-valued dimensions (looked up by name), getAllDims, getTopChildren, getSpecificValue, hits, drill-down, the index (nothing is written) and SortedSetDocValues facets (looked up by name).
  • Reach. getTopDims exists since 9.2.0. GitHub code search of the Elasticsearch, OpenSearch and Solr default branches found 0 hits each for getTopDims, FastTaxonomyFacetCounts and DirectoryTaxonomyReader.

Minimal repro

The test shows that taxonomy getTopDims(1, 10) returns dim y's value, childCount and children under dim x's name when x (multi-valued, requireDimCount, created first, 2 hits) replaces y (1 hit) in the top-dims queue, because the replacement keeps y's dimOrd.

TestTaxonomyGetTopDimsMultiValued.java (JUnit test)66 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.facet.taxonomy;

import org.apache.lucene.document.Document;
import org.apache.lucene.facet.FacetField;
import org.apache.lucene.facet.Facets;
import org.apache.lucene.facet.FacetsCollector;
import org.apache.lucene.facet.FacetsCollectorManager;
import org.apache.lucene.facet.FacetsConfig;
import org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyReader;
import org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyWriter;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTaxonomyGetTopDimsMultiValued extends LuceneTestCase {

  public void testOlderDimWithMoreHits() throws Exception {
    FacetsConfig config = new FacetsConfig();
    for (String dim : new String[] {"x", "y"}) {
      config.setMultiValued(dim, true);
      config.setRequireDimCount(dim, true);
    }
    try (Directory dir = new ByteBuffersDirectory();
        Directory taxoDir = new ByteBuffersDirectory();
        IndexWriter writer = new IndexWriter(dir, new IndexWriterConfig());
        DirectoryTaxonomyWriter taxoWriter = new DirectoryTaxonomyWriter(taxoDir)) {
      Document doc = new Document();
      doc.add(new FacetField("x", "x1"));
      writer.addDocument(config.build(taxoWriter, doc));
      doc = new Document();
      doc.add(new FacetField("x", "x2"));
      doc.add(new FacetField("y", "y1"));
      writer.addDocument(config.build(taxoWriter, doc));
      try (DirectoryReader reader = DirectoryReader.open(writer);
          DirectoryTaxonomyReader taxoReader = new DirectoryTaxonomyReader(taxoWriter)) {
        FacetsCollector hits =
            new IndexSearcher(reader)
                .search(MatchAllDocsQuery.INSTANCE, new FacetsCollectorManager());
        Facets facets = new FastTaxonomyFacetCounts(taxoReader, config, hits);
        // Facets#getTopDims javadoc: the same as getAllDims, then using only the first topNDims
        assertEquals(facets.getAllDims(10).subList(0, 1), facets.getTopDims(1, 10));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-363/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/facet/build/libs/lucene-facet-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.facet.taxonomy.TestTaxonomyGetTopDimsMultiValued
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected (Facets#getTopDims javadoc: same as getAllDims, first topNDims): dim=x value=2 childCount=2 x1 (1) x2 (1); actual (-ea and -da): dim=x value=1 childCount=1 y1 (1).

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

More files, environment, full output48 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<[dim=x path=[] value=2 childCount=2
  x1 (1)
  x2 (1)
]> but was:<[dim=x path=[] value=1 childCount=1
  y1 (1)
]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.facet.taxonomy.TestTaxonomyGetTopDimsMultiValued.testOlderDimWithMoreHits(TestTaxonomyGetTopDimsMultiValued.java:62)

Why it looks unintended

  • The javadoc: getTopDims results "should be the same as calling getAllDims and then only using the first topNDims".
  • It fails with requireDimCount, the setting the javadoc recommends; the SortedSetDocValues version of the same algorithm looks children up by name and is correct.
  • No upstream test runs the replacement branch with a multi-valued dimension; no commit ever set dimOrd there.
The full argument289 words
  • Contract. Facets.java:60-64: "Returns labels for topN dimensions and their topNChildren sorted by the number of hits/aggregated values that dimension matched. Results should be the same as calling getAllDims and then only using the first topNDims. Note that dims should be configured as requiring dim counts if using this functionality to ensure accurate counts are available". FacetResult: dim is the "Dimension that was requested", value the "Total number of documents containing a value for this path", labelValues its "Child counts" (FacetResult.java:24-40). The base method is getAllDims(topNChildren).subList(...) (Facets.java:69-72). Taxonomy facets inherited it only in development history (before #779/#806 on main and #889 on branch_9x); no release shipped that, since 9.1.0 has no getTopDims and 9.2.0 contains #889.
  • Twin. AbstractSortedSetDocValueFacetCounts#getTopDims runs the same queue and the same two-field update (:206-208), but has no ordinal in its entry and looks children up by name (:226, :228): correct.
  • Tests. TestTaxonomyFacetCounts#testCountRoot (getTopDims(1, 10) at :677), testGetFacetResultsTwice (:722) and testSparseFacets (:253), and TestTaxonomyFacetValueSource (:219, :702) use single-valued dimensions; testRandom asks for every dimension (:1062). The multi-valued tests of TestTaxonomyFacetCounts (setMultiValued at :390, :430, :481, :533) do not call getTopDims, and TestTaxonomyFacetAssociations calls getTopDims(10, 10) for its 4 multi-valued dimensions, more slots than dimensions. A grep of the test trees finds no test that runs the replacement branch with a multi-valued dimension. The tests compare with FacetResult#equals (for example :678, :722), which ignores dim.
  • History. Introduced by the LUCENE-10488 optimisation (#779, #806, backport #889). git log -S 'bottomDim.dimOrd' over the full history finds no commit; nobody raised it in the review threads of 12 related PRs.
The devil's-advocate check203 words

The strongest "intended" case: Facets is @lucene.experimental; the javadoc says "should be the same", soft wording, and advises requireDimCount; the finder's first repro needs a three-way tie broken by name; the default single-valued dimensions are fine; and FacetResult#equals ignores the name anyway.

Why it fails: @lucene.experimental is about API stability, not wrong numbers. The recommended requireDimCount setting fails with no tie at all (x/y, a/b/c), and the value and childCount are wrong too, so equals sees it. Multi-valued dimensions are a designed-for input: getTopDims has its own multiValued/requireDimCount branch (TaxonomyFacets.java:629-638), and multi-valued data cannot be indexed any other way. The SortedSetDocValues version of the same algorithm is correct, and the fix costs nothing. Both reviews that rated the intended-behaviour argument called it weak; the devil's advocate rejected it (bug real, high confidence) and the adjudication confirmed the bug with high confidence. Two narrower points stand: without requireDimCount, ranking dimensions by the -1 placeholder and then by name is intended (accurate counts need requireDimCount), so there only the wrong children and childCount are the bug; and the association facets cannot set requireDimCount at all (FacetsConfig.java:305-307), so for them the javadoc's advice cannot be followed.

Suggested fix

Recommended Set the ordinal when the bottom entry is reused

Add bottomDim.dimOrd = dimOrd; next to bottomDim.dim = dim; in TaxonomyFacets#getTopDims, so the reused queue entry gets the winner's ordinal. The repro, the other repros and the whole lucene/facet test suite pass with it, assertions on and off. Mention open PR #13287, which edits the same lines.

                DimValue bottomDim = pq.top();
                bottomDim.dim = dim;
+               bottomDim.dimOrd = dimOrd;
                bottomDim.value = dimValue;
                pq.updateTop();
All options and trade-offs4 options · 619 words

The reused entry then describes the winner completely and the result phase computes the winner's own children; no extra work. Until a fix ships, applications can call getAllDims(topNChildren) and take the first topNDims entries, which is what the base Facets#getTopDims does. Only query-time results change, so no reindex is needed. Either way, add a test that compares getTopDims with the getAllDims prefix for multi-valued dimensions with fewer slots than dimensions and an older dimension winning, and that compares the dimension name, since FacetResult#equals ignores it. Open PR #13287 (2024, stale) rewrites the same replacement block for zero-valued dimensions and also sets only dim and value; a fix will conflict with it. The sibling LFV-368 (dimensions without hits admitted with the -1 placeholder) needs its own fix in the admission line.

Set the ordinal when the bottom entry is reused (validated by the checks) (recommended)

TaxonomyFacets.java:659-662: one added line.

                DimValue bottomDim = pq.top();
                bottomDim.dim = dim;
+               bottomDim.dimOrd = dimOrd;
                bottomDim.value = dimValue;
                pq.updateTop();

Trade-offs

None found: the smallest change, no extra work, and it leaves the rest of the method as it is. It keeps the mutable entry, so a future edit could drop a field again; option B removes that risk.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: the new test fails 2 of 3 on stock with assertions on and off; with the fix, the new test plus TestTaxonomyFacetCounts, TestTaxonomyFacetAssociations and TestTaxonomyFacetValueSource pass (4 suites, 44 tests); the whole :lucene:facet:test passes with assertions on (40 suites, 309 tests, 7 skipped, 0 failures) and off (309 tests, 8 skipped, 0 failures); :lucene:facet:check -x test (google-java-format, ecj lint, forbidden APIs) passes.
  • Standalone runner with the fix shadowed: the new test 3 of 3 with -ea and -da; the independent reproducer's class 7 of 7 (4 of 7 fail on stock). Earlier, with the same line, the finder's and the devil's advocate's tests passed on main, the release-sweep class passed 9 of 9 on main, 10.5.2 and 9.12.3, and the security probe's contract, no-hit and control tests passed (5 of 6; its informational test, which asserts the stale label, flips as expected).

Add a fresh entry with insertWithOverflow and make the entry immutable (validated by the checks)

Replace the add/replace block with pq.insertWithOverflow(new DimValue(dim, dimOrd, dimValue)); and make the three DimValue fields final (+6/-15). The queue's comparator (value, then reverse dimension name, TaxonomyFacets.java:610-614) is exactly the hand-written replacement condition, so the same dimensions are kept.

          if (valueComparator.compare(dimValue, 0) != 0) {
            pq.insertWithOverflow(new DimValue(dim, dimOrd, dimValue));
          }

Trade-offs

Makes a stale field impossible and shortens the method; allocates one small object per dimension with a non-zero value instead of at most topNDims. A larger diff than option A.

What the checks validated

Standalone runner on main a9779ef3c193: the new test 3 of 3, the independent reproducer's class 7 of 7, the finder's 2 of 2; gradle: the whole :lucene:facet:test with assertions on (40 suites, 309 tests, 0 failures) and :lucene:facet:check -x test pass.

Look the children up by name, as the SortedSetDocValues version does

Drop dimOrd from DimValue and resolve the dimension's ordinal from its name in the result phase.

Trade-offs

Not validated. One ordinal lookup per returned dimension, and more change than option A for the same effect.

Workaround: take a prefix of getAllDims

Call getAllDims(topNChildren) and keep the first topNDims entries, or ask getTopDims for at least as many dimensions as the code admits (every dimension with a non-zero value, including multi-valued dimensions without requireDimCount, which are admitted at -1 even without hits). More slots do not avoid the sibling LFV-368's null entries.

Trade-offs

Correct, but gives up the optimisation getTopDims exists for; not a fix.

Downstream note

GitHub code search of Elasticsearch, OpenSearch and Solr found no use of getTopDims, FastTaxonomyFacetCounts or DirectoryTaxonomyReader, so the exposure found is in applications that use Lucene's facet module directly.

How it was found

An AI finder suspected the stale ordinal while reading TaxonomyFacets#getTopDims to build a bounded-exhaustive check of the facet result API, which compares getTopDims with the getAllDims prefix over every subset of hits of 12 modelled documents. The check then reported wrong taxonomy entries in every stock run, and the finder reduced it to a JUnit test.

The method in full169 words

Method: semi-formal. The hypothesis came from code reading; a bounded-exhaustive translation-validation harness witnessed it, and a public-API JUnit test reproduced it.

  • The harness models 12 documents over taxonomy and SortedSetDocValues facets and checks every one of the 2^12 hit subsets per seed, with and without 1,100 padding documents, several segment layouts and deletions, comparing getTopDims with the getAllDims prefix and with exact counts.
  • On stock main every one of 8 runs reported wrong taxonomy getTopDims results (seed 1: 1,920 mismatching taxonomy getTopDims results without a null entry among 3,504 mismatches in 703,437 checks; the other 1,584 contain a null entry, in taxonomy and SortedSetDocValues facets, the symptom of the sibling bug LFV-368).
  • With this one-line fix and the sibling's fix, 184 of 192 configurations completed with 0 mismatches in 131,356,014 checks; the other 8 stopped at the harness's own setup check (AssertionError: maxDoc) before checking anything.
  • The finder then wrote a one-document JUnit test (three tied dimensions); the independent reproducer added the no-tie x/y case used here.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26, refreshed 2026-10-27): 123 logged queries over GitHub issues, PRs and commits, JIRA, four mailing lists, downstream projects and the web, the review threads of 12 related PRs and all open PRs. Open PR #13287 edits the same lines for another problem and keeps the ordinal stale.

Searches5 searches

Keyword search only: GitHub search does not index diffs, 8 queries hit the shared rate limit and were re-run, and private reports are not visible. Nearest items: #13287 (open, stale, 2024: zero-valued dimensions in the same block, still sets only dim and value); #13191 / #13198 (open: a zero-aggregation test failure with single-valued dimensions only, which this bug does not reach since 9.3.0); #11524, #779, #806, #889 (the introducing optimisation); #11361, #747, #777 (the feature and the SortedSetDocValues versions); #915, #12966 (refactors); #14008 (a different, fixed taxonomy wrong-count bug).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-26; refreshed 2026-10-27 (re-run by the fact check)the scripted prior-report search in two runs (32 issue and PR queries: getTopDims alone and with multiValued, dimOrd, wrong, getAllDims; the class names; bottomDim; createFacetResult; symptom phrases) and 13 manual searches; on 2026-10-27 issue and PR searches for getTopDims againno report; the items listed above
Review threads and open PRs2026-10-26comments and reviews of #747, #773, #777, #779, #806, #889, #915, #12966, #13358, #13568, #13287 and #13198 grepped for dimOrd, bottomDim, updateTop and DimValue; all 410 open PRs listed and the changed files of 162 readonly #13287 touches the getTopDims block, and it keeps dimOrd stale
Commits2026-10-266 commit queries; git log -S 'bottomDim.dim = dim' and -S 'bottomDim.dimOrd' over the full historythe code came with #779 and #806; no commit has ever set bottomDim.dimOrd
Mailing lists (dev@, java-user@, issues@, commits@) and JIRA2026-10-2640 list queries, 8 JIRA text queries, the comments of LUCENE-10325 and LUCENE-10488only notifications of the PRs above and unrelated facet threads
Downstream (Elasticsearch, OpenSearch, Solr) and the web2026-10-264 downstream queries plus code searches for getTopDims, FastTaxonomyFacetCounts and DirectoryTaxonomyReader; 9 web searchesnothing; code search found 0 hits for those three symbols in each project

History

Introduced by the 2022 getTopDims optimisation for taxonomy facets (#779, #806; backport #889) and first released in 9.2.0. Every release from 9.2.0 to 10.5.2 and main fails; from 9.3.0 (#915) every multi-valued dimension is affected and the value and childCount are wrong too. Not fixed upstream.

Versions tested7 rows

Introduced by LUCENE-10488 "Optimize Facets#getTopDims" (issue #11524): #779 (57f8cb2fd6b, IntTaxonomyFacets) and #806 (f0ec2261672, FloatTaxonomyFacets) by @Yuti-G, approved by @gsmiller, merged 2022-05-13; branch_9x backport #889 (87655fd015c) the same day. Before it (and before #779/#806 on main), taxonomy facets inherited the correct base getTopDims, but only in development history: no release shipped that, since 9.1.0 has no getTopDims and 9.2.0 contains #889. 9.3.0: LUCENE-10585 (#915, 8db1e41fc07; branch_9x 075f5103e9a) sent every multi-valued dimension down the uncached path and built the value and childCount from the recomputed children; hierarchical single-valued dimensions, affected in 9.2.0, have been cached since. #12966 (9ba4af7b883, first in 10.0.0; 9.11.0 got the backport 42884fab46d, #13358) merged the int and float copies into TaxonomyFacets and #14761 (a7704f5242d) switched to a comparator queue; both kept the omission. Upstream main still has it (2026-10-27).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test: 2 of 3 fail (gradle and the standalone runner, assertions on and off), x value=1 childCount=1 [y1 (1)]
10.5.2, 10.0.0, 9.12.3, 9.3.0 affectedthis doc's test: 2 of 3 fail, the same output (10.5.2 also with assertions off)
9.2.0 children onlythis doc's test: 2 of 3 fail; the value is right, the children and childCount are not: x value=2 childCount=1 [y1 (1)]
9.2.0 to 10.5.2 (31 releases), main ea57360912ed release sweepthe 9-test history class: 4 of 9 fail on every release; the 3 controls pass
9.2.0 with the pre-#889 taxonomy classes bisect9 of 9 pass: the backport #889 introduced it
9.1.0 no APIcompile error: Facets has no getTopDims
fix (one line) on main, 10.5.2, 9.12.3 not affectedevery repro passes; the whole facet suite passes on main

Independent checks

An independent reproducer, a devil's advocate, an adjudication and an audit each confirmed it with executed tests; read-only reviews by another model family agreed on the root cause, one giving a procedural "not real" because it could not run Java. This doc's gradle runs reproduced it on main a9779ef3c193.

Checks and verdicts303 words

Not done: a cross-model review that executes code (both reviews ran read-only; separate runs re-ran the repro).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateThe bounded-exhaustive harness found wrong taxonomy entries in all 8 stock runs; the JUnit test fails 2 of 2 on main with assertions on and off and on 7 releases from 9.4.2 to 10.5.2, 1 of 2 on 9.2.0 (the requireDimCount case), and passes with the fix.
cross-model review, refute lens not real (low, procedural)An independent reviewer from another model family (OpenAI GPT-5.6), read-only: could not run Java, so "not reproduced" by construction; it agreed on the root cause, found the inputs in contract and the intended argument weak; a separate re-run failed 2 of 2.
independent reproducer realOwn 7-test class: 4 of 7 fail on main (assertions on and off), 10.5.2 and 9.12.3, 40 of 70 over 10 iterations, 7 of 7 with the fix; found that the value and childCount are wrong too and that the list can fall out of order.
cross-model review, intended lens real (high)OpenAI GPT-5.6, read-only: the intended argument is weak; a separate re-run failed 2 of 2.
devil's advocate real (high)Built the strongest intended case and showed why it fails; its no-tie variant fails on main and 10.5.2 and passes with the fix.
adjudication confirmed (high)Re-ran the independent reproducer with a new seed: 4 of 7 fail with assertions on and off, 7 of 7 with the fix; not a duplicate.
audit keepRe-ran the independent reproducer with fresh seeds: 4 of 7 fail, 7 of 7 with the fix.
this doc reproducedNew 3-test class through gradle and the standalone runner on main a9779ef3c193 and five releases; fixes A and B validated.

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

Finding record

LFV-363, snapshot of 2026-11-06

Registry title
Taxonomy Facets#getTopDims returns another dimension's children, childCount and value under a multi-valued dimension's name, because replacing the bottom of the top-dims queue updates dim and value but not dimOrd (released since 9.2.0)
Area
facet / taxonomy facets (TaxonomyFacets#getTopDims, shared by FastTaxonomyFacetCounts and the int and float association facets)
Priority
Medium, rank 176: GetTopDims can silently give one dimension's entry another dimension's children, which is plausible, but it needs multi-valued dims with requireDimCount, a narrower configuration than its sibling LFV-368.
Impact class
silent wrong results
Impact note
Applications that call Facets#getTopDims(topNDims, topNChildren) on taxonomy facets (FastTaxonomyFacetCounts, TaxonomyFacetIntAssociations, TaxonomyFacetFloatAssociations) with dims configured through FacetsConfig#setMultiValued (with requireDimCount, as the getTopDims javadoc advises, or without) and with more dims with hits than topNDims silently get an entry that has the right dim name but another dimension's children, childCount and (since 9.3.0) value whenever a dim visited later outranks the queue bottom (dims are visited newest-created first, so typically when an older dim wins); the winner's own children are missing and the list can be out of value order; default codec, IndexWriterConfig and taxonomy, one document suffices, -ea and -da, every release from 9.2.0 to 10.6.0 (10.6.0 by reading) and main; flat single-valued dims (the default DimConfig), getAllDims, getTopChildren, hits and drill-down are correct, and nothing is written to the index.
Summary
You mark taxonomy facet dims as multi-valued, run a query and call getTopDims(1, 10), and expect the same as getAllDims(10).subList(0, 1), as the Facets#getTopDims javadoc says. When the winning dim is visited after the dim it pushes out of the internal top-dims queue (dim x with 2 hits, created first, and dim y with 1 hit, both with requireDimCount), Lucene returns 'dim=x value=1 childCount=1 y1 (1)' instead of 'dim=x value=2 childCount=2 x1 (1) x2 (1)', that is y's children and count under x's name. The queue-replacement branch sets bottomDim.dim and bottomDim.value but never bottomDim.dimOrd, and the children of multi-valued dims are then computed from that stale ordinal.
Introduced
57f8cb2fd6bcc0a27f89dc8c36b43bfd812ded46 (main, apache/lucene#779 'LUCENE-10488: Optimize Facets#getTopDims in IntTaxonomyFacets', by Yuti-G, approved by gsmiller) and f0ec226167230a42b27a4b946c9c0f74d0f0abfc (main, #806, the same block in FloatTaxonomyFacets); first released through the branch_9x backport 87655fd015ceef36c4210a0d20f1071068544fe4 (#889, gsmiller) in 9.2.0; issue #11524 (LUCENE-10488). [read: git log -S 'bottomDim.dim = dim' and git blame at 9ba4af7b883^ in the history clone; git log -S 'bottomDim.dimOrd' finds no commit ever setting it; exec: 9.2.0 jars with IntTaxonomyFacets and FloatTaxonomyFacets from 87655fd015c^ pass 9/9, the stock 9.2.0 jars fail 4/9]. Before it, taxonomy facets inherited the default Facets#getTopDims (a getAllDims prefix), which is correct. LUCENE-10585 (#915, 8db1e41fc07; branch_9x 075f5103e9a, 9.3.0) changed which dims take the stale-ordinal path; #12966 (9ba4af7b883) and #14761 (a7704f5242d) moved the code and kept the omission (2022-05-13 (#779 merged 22:54:32Z, #806 22:54:41Z, backport #889 23:41:13Z))
First affected version
9.2.0 [exec] (the first release with Facets#getTopDims, -ea and -da; there the affected dims are multi-valued dims with requireDimCount and hierarchical dims; from 9.3.0 every multi-valued dim, with or without requireDimCount, and no longer hierarchical single-valued dims); 9.1.0 has no getTopDims [exec: compile error, the only missing symbol is Facets#getTopDims(int,int)]
Affected versions
Every release from 9.2.0 to 10.5.2 (31 releases), 10.6.0 (by reading) and main ea57360912ed; not fixed upstream. [exec] follow-up: a 9-test JUnit (6 eviction cases over FastTaxonomyFacetCounts and TaxonomyFacetFloatAssociations with a value source and with associations, 3 controls) fails 4 of 9 on all 31 releases and main with -ea, and also with -da on 9.2.0, 9.3.0, 9.12.3, 10.5.2 and main; the controls pass everywhere; the one-line fix makes 9/9 pass on 9.12.3, 10.5.2 and main. The independent reproduction's JUnit (FastTaxonomyFacetCounts and TaxonomyFacetIntAssociations) fails 4 of 7 on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check's no-tie variant fails on main (-ea, -da) and 10.5.2; the finder's JUnit fails on 9.2.0 (requireDimCount case only), 9.4.2, 9.6.0, 9.8.0, 9.9.2, 9.10.0, 9.12.3, 10.5.2 and main. [read] branch_10x and branch_10_6 have a TaxonomyFacets.java identical to 10.5.2 (not executed); the newest upstream main commit touching the file is a7704f5242d (2025-06-18). All runs on aarch64 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 could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only, the prior-report record, verdict new: 123 logged queries: a scripted prior-report search in two runs (32 GitHub issue/PR, 6 commit, 4 downstream over Elasticsearch, OpenSearch and Solr, 40 mailing-list over dev@, java-user@, issues@ and commits@, 8 JIRA), 9 web searches, 13 manual gh searches, JIRA comments of LUCENE-10325 and LUCENE-10488, the review threads of #747, #773, #777, #779, #806, #889, #915, #12966, #13358, #13568, #13287 and #13198 grepped for dimOrd, bottomDim, updateTop and DimValue, all 410 open apache/lucene PRs listed with the changed files of 162 of them read, and git log -S over the full history; 8 of the manual gh searches hit the shared GitHub rate limit and were re-run successfully through a scripted prior-report search); no hit is this bug. Closest item: open, stale PR #13287 (stefanvodita, 2024-04-10, 'Don't assume non-zero aggregations in getTopDims or test checkResults', for #13191) rewrites the same replacement block for zero-valued dims and still sets only dim and value: it neither reports nor fixes this bug, but a fix PR would conflict with it. Related, not reports: #13191 / #13198 (zero-aggregation test failure with single-valued dims only), #11524 / #779 / #806 / #889 (the introducing optimisation; nobody raised the stale ordinal in review), #11361 / #747 / #777 (the feature and the SSDV versions), #915 and #12966 (refactors), #14008 (a different taxonomy wrong-results bug, fixed). Not fixed upstream.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2, 10.0.0, 9.12.3, 9.3.0 and 9.2.0 release jars (9.1.0 has no getTopDims). Earlier checks ran on main ea57360912ed, whose facet module is identical, and on all 31 releases from 9.2.0 to 10.5.2. Fix A was validated in the scratch worktree: the new test, the closest suites and the whole lucene/facet suite with assertions on and off, and :lucene:facet:check -x test; fix B with the whole suite, assertions on.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:46-55 (DimValue holds dim, dimOrd, value), :610-614 (queue ordered by value, then dim name), :619-669 (loop over dims, youngest-created first: multi-valued dims take the indexed dim count or -1 at :629-638, single-valued dims cache their children by name in intermediateResults at :639-651; admission at :652; replacement at :656-663 sets dim and value only, :660-661), :671-689 (result phase: children by name if cached, else getTopChildrenForPath(dimConfig, dimValue.dimOrd, topNChildren) at :683; createFacetResult(topChildrenForPath, dim) at :685; assert at :686)
  • lucene/facet/src/java/org/apache/lucene/facet/Facets.java:59-67 (getTopDims javadoc: 'Results should be the same as calling getAllDims and then only using the first topNDims'; requireDimCount advice)
  • lucene/facet/src/java/org/apache/lucene/facet/sortedset/AbstractSortedSetDocValueFacetCounts.java:200-228 (SSDV getTopDims rebuilds each result from the dim name; correct)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/TaxonomyIndexArrays.java (children/siblings arrays: newest child first)
  • lucene/demo/src/java/org/apache/lucene/demo/facet/AssociationsFacetsExample.java:59-61 (the demo marks its association dims multi-valued)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:46-56, 467-516 (506-512), 595-690 (610-614, 620, 629-638, 639-651, 652-662, 668, 678-686)
  • lucene/facet/src/java/org/apache/lucene/facet/Facets.java:60-72
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsConfig.java:180-181, 190-194, 233-241, 386-397
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/TaxonomyIndexArrays.java:253-255, 269
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/FacetResult.java:72-79; FacetsConfig.java:137, 233-241
  • lucene/demo/src/java/org/apache/lucene/demo/facet/AssociationsFacetsExample.java:59-61
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsConfig.java:305-307 (AssociationFacetField cannot requireDimCount); taxonomy/TaxonomyFacets.java:629-637, 652
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/Facets.java:25, 60-72; FacetResult.java:24-40, 72-79
  • lucene/facet/src/java/org/apache/lucene/facet/sortedset/AbstractSortedSetDocValueFacetCounts.java:143, 200-228
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestTaxonomyFacetCounts.java:253, 390, 430, 481, 533, 649-678, 694-722, 1062; TestTaxonomyFacetValueSource.java:219, 702; TestTaxonomyFacetAssociations.java:84-91, 238-380 (getTopDims(10, 10) at 238, 269, 288, 327, 359, 380); grep of lucene/*/src/test for getTopDims and setMultiValued
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:61-64, 231-243 (insertWithOverflow)
Prior reports
  • read-only gh searches of 2026-10-27 (issues and PRs for getTopDims; PR #13287 state; upstream main TaxonomyFacets.java:659-661 and its newest commit a7704f5242d)
History
  • a Lucene clone (git tag --contains 87655fd015c: 9.2.0 first)

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

Repro files

About the diagrams

A light summary card, made in parallel with this doc from an executed probe trace on main a9779ef3c193 (assertions on and off, and with the fix as a control); the PNG is the summary card.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.