All findings · Lucene finding · LFV-527 · ssdv-hierarchical-dim-not-indexed-npe

SortedSetDocValues faceting throws NullPointerException for the root of a hierarchical dimension that no document has, instead of returning null

Ask SortedSetDocValues faceting for the top children of a dimension that FacetsConfig declares hierarchical but that no document in the reader has, and you get a NullPointerException instead of the documented null. A flat dimension in the same state returns null; 9.2.0 returned null too, every release since 9.3.0 throws.

Summary

Priority
Medium, rank 232Hierarchical SortedSetDocValues facets throw NPE for a configured dim absent from the reader, a deterministic loud regression since 9.3 on sharded or fresh indexes, so it follows LFV-526.
Impact
crash
Since
9.3.0 (#915, 2022)introduced 2022-05-29 · about 4 years
Affected
9.3.0 to 10.5.2 and main
Status
documented
Found by
model-based metamorphic testing of the Facets API

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

The finding in 30 seconds

SSDV faceting builds one ordinal tree per hierarchical dimension, and only for dimensions that occur in the reader. Since 9.3.0 a request for the root of a hierarchical dimension reads that tree without checking that it exists; the flat branch and the path branch of the same method do check and return null.

Summary card of the bug. Steps: 1. config.setHierarchical("category", true); index B has doc 0 with author bob and doc 1 with author lisa, so no document in index B has category and the terms of $facets are only ord 0 author/bob and ord 1 author/lisa. 2. state = new DefaultSortedSetDocValuesReaderState(reader, config): author maps to OrdRange 0..1, category has no entry, so state.getDimTree("category") returns null. 3. new SortedSetDocValuesFacetCounts(state, hits).getTopChildren(10, "category"): with no path, prepareChildIteration takes the shortcut pathOrd = dimTree.dimStartOrd (line 257) with no null check. Expected: getTopChildren and getAllChildren return null (Facets javadoc: "Returns null if ... this dimension was never seen"), as a flat dim, a missing path and getTopChildren in 9.2.0 do. Actual: both throw, with -ea and -da, at line 257 of prepareChildIteration: NullPointerException: Cannot read field "dimStartOrd" because "dimTree" is null. Same NPE: the count-all and the concurrent counts, DrillSideways facets, and index A once its category document is deleted and merged away. Correct on the same reader: flat "publisher" returns null; getTopChildren(10, "category", "books") returns null; getSpecificValue("category") returns -1. With no hits, SortedSetDocValuesFacetCounts.getTopChildren returns null before line 257 (since 9.11.0), but getAllChildren still throws. Why: with an empty path, line 257 reads dimTree.dimStartOrd, but state.getDimTree(dim) returns prefixToDimTree.get(dim), which is null when no document has the dim (DefaultSortedSetDocValuesReaderState.java:386); the flat branch checks this case (lines 270-272), and a missing path returns null through lookupTerm < 0 (line 259); Lucene 9.2.0 always looked the term up first; LUCENE-10585 (#915), released in 9.3.0, added the shortcut. Candidate fix: if (dimTree == null) { return null; } after line 252. Affected: every release from 9.3.0 to 10.5.2, and main (executed: 9.3.0, 10.5.2 and main throw; 9.2.0's getTopChildren returns null). Needs a dim configured hierarchical (FacetsConfig.setHierarchical) that no document in the reader has: a fresh, sharded or per-tenant index.
Two indexes, one FacetsConfig with category hierarchical: index A has a document with a category, index B (doc 0 author bob, doc 1 author lisa) has none. For index B getDimTree("category") returns null and the root request throws at dimTree.dimStartOrd (line 257), while a flat dimension and a path request return null. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You call config.setHierarchical("category", true) and index one document that has only a flat facet, author=bob. No document has a category yet.
  2. You ask for the top categories: getTopChildren(10, "category") or getAllChildren("category") on SortedSetDocValuesFacetCounts. You expect null: the Facets javadoc returns null "if this dimension was never seen", and the absent flat dimension publisher does return null.
  3. Instead both throw NullPointerException: Cannot read field "dimStartOrd" because "dimTree" is null, with assertions on and off, also from ConcurrentSortedSetDocValuesFacetCounts and the count-all constructor: 4 of the repro's 5 tests fail, the control passes.
  4. Why: since 9.3.0 a request without a path reads state.getDimTree(dim).dimStartOrd directly, and getDimTree returns null for a dimension with no indexed term, because its trees are built only from indexed terms. A path below it, getTopChildren(10, "category", "books"), still returns null.
  5. The same happens after every document with category is deleted and merged away (the call worked before the delete), and when DrillSideways results read a drill-down on such a dimension through getAllDims.

Who is affected

Applications on 9.3.0 or later that use SSDV facets with a dimension set hierarchical (a non-default FacetsConfig setting) and ask for its root on a reader where no document has it: a newly configured dimension, a shard or tenant index without it, or all its documents deleted and merged. Deterministic at any size; no wrong results or index damage.

Mechanism, terms and numbers592 words

Facets#getTopChildren and Facets#getAllChildren return null "if this dimension was never seen", and SortedSetDocValues (SSDV) faceting has done so for flat dimensions since LUCENE-9971 (9.0.0). For a dimension that FacetsConfig declares hierarchical, the same request throws when no document in the reader has the dimension: NullPointerException: Cannot read field "dimStartOrd" because "dimTree" is null, from SortedSetDocValuesFacetCounts and ConcurrentSortedSetDocValuesFacetCounts, with assertions on and off. A shortcut added in 9.3.0 by #915 (LUCENE-10585, a cleanup) reads state.getDimTree(dim).dimStartOrd for a request without a path, and getDimTree returns null for a dimension with no indexed term. The flat branch next to it, a path below the same dimension, getSpecificValue and taxonomy faceting all treat that state as "never seen". In 9.1.0 and 9.2.0 getTopChildren returns null (getAllChildren did not exist yet: LUCENE-10550 added it in 9.3.0, already affected); 9.3.0 to 10.5.2 and main throw. A 4-line null check that mirrors the flat branch restores the documented result.

Terms

  • SSDV faceting: facet counting over SortedSetDocValuesFacetField values, without a taxonomy index. DefaultSortedSetDocValuesReaderState reads the facet terms of a reader once; SortedSetDocValuesFacetCounts and ConcurrentSortedSetDocValuesFacetCounts count them for a query and share the base class AbstractSortedSetDocValueFacetCounts.
  • Hierarchical dimension: FacetsConfig#setHierarchical(dim, true) lets a dimension have paths such as category/books/fiction. For each such dimension found in the reader, the state builds a DimTree (its first ordinal dimStartOrd plus child and sibling links); a flat dimension gets an OrdRange instead.
  • Root request: getTopChildren(n, dim) or getAllChildren(dim) with no path asks for the dimension's top-level children.

The bug

  1. getTopChildren (AbstractSortedSetDocValueFacetCounts.java:56-63 on main a9779ef3c193) returns null when hasCounts() is false (:58), else calls getTopChildrenForPath, which calls prepareChildIteration (:294). getAllChildren (:66-75) calls prepareChildIteration first (:68) and checks hasCounts() only afterwards (:73).
  2. prepareChildIteration (:247-285), hierarchical branch: DimTree dimTree = state.getDimTree(dim); (:252). With a path it runs dv.lookupTerm(...) (:254) and returns null for pathOrd < 0 ("path was never indexed", :259-262). Without a path it takes the shortcut "If there's no path, this is a little more efficient to just look up the dim:" pathOrd = dimTree.dimStartOrd; (:256-257), with no null check.
  3. DefaultSortedSetDocValuesReaderState#getDimTree returns prefixToDimTree.get(dim) (:381-387). The map gets an entry only while the state walks a dimension's indexed terms (:185), so a dimension with no indexed term has none, getDimTree returns null and :257 throws.
  4. The flat branch handles the same case: if (ordRange == null) { // means dimension was never indexed return null; } (:269-273).
  5. So getAllChildren(dim) throws whenever it is called; getTopChildren(n, dim) throws when hasCounts() is true: in practice when the query had hits (strictly, once a segment with hits and the facet field was counted, SortedSetDocValuesFacetCounts.java:79-83, 173-187), always with the count-all constructor, and always for ConcurrentSortedSetDocValuesFacetCounts, whose hasCounts() returns true (ConcurrentSortedSetDocValuesFacetCounts.java:80-84).
  6. Not affected: requests with a path, getSpecificValue (term lookup, -1), and getAllDims/getTopDims of the plain counts, which iterate the indexed dimensions only.

The numbers

One document with only author=bob, category configured hierarchical, default codec and searcher. getTopChildren(10, "category") with hits, getAllChildren("category"), the count-all constructor and the concurrent class: 4 of 4 throw the NPE at AbstractSortedSetDocValueFacetCounts.java:257. Controls on the same state: flat publisher null from both calls; getTopChildren(10, "category", "books") and getAllChildren("category", "books") null; getSpecificValue("category") -1. The independent reproducer adds: getAllChildren after a zero-hit query throws, and getTopChildren returns a result while one document has the dimension, then throws after that document is deleted and forceMerge(1) runs.

Exposure and likelihood347 words
  • Who. Applications that use SortedSetDocValues facets with a dimension declared hierarchical (FacetsConfig#setHierarchical, not the default) and ask for its root, getTopChildren(n, dim) or getAllChildren(dim), on a reader in which no document has that dimension: the dimension is configured before its first document arrives; one shard, tenant, time partition or filtered index of an application-wide FacetsConfig lacks it; or every document with it was deleted and merged away (unmerged deletes still keep it "seen"). The dimension must be absent from the whole reader, because the state is built from the reader's terms: a filtered index means one built from a subset of the documents, and a query or filter that excludes every document with the dimension leaves it "seen" and does not trigger the bug (executed, probe S2).
  • Through Lucene's own code. DrillSideways builds one SortedSetDocValuesFacetCounts per drill-sideways dimension and returns them in a MultiFacets, whose getAllDims calls getTopChildren(topN, dim) on each (MultiFacets.java:81-88). A drill-down on a configured hierarchical dimension that no document has returns its hits (0), then getAllDims(10) on the result throws (executed on ea57360912ed and 10.5.2).
  • Default dimension config. An application whose getDefaultDimConfig (FacetsConfig.java:142-151, for dimensions "not known beforehand") makes hierarchical the default gets the exception for any dimension name that is not indexed (executed).
  • What happens. The facet call throws instead of returning null; a caller without per-facet error handling fails the whole request. After the exception the same Facets object and reader state answer other dimensions correctly (executed). No wrong results, no index change.
  • Default vs opt-in. The only non-default setting is the hierarchical flag of an SSDV dimension: the diagram probe, a plain main() with ByteBuffersDirectory, new IndexWriterConfig() (default codec) and a plain IndexSearcher, throws the same NPE with -ea and -da; the JUnit repro, under LuceneTestCase's randomized directory, codec and IndexWriterConfig, failed with every seed run (executed).
  • Scale. Deterministic, one document is enough, assertions on or off.
  • Workaround. Check state.getDimTree(dim) != null before asking for the root of a hierarchical dimension (the method throws UnsupportedOperationException for flat ones).

Minimal repro

The test shows that SortedSetDocValuesFacetCounts#getAllChildren throws for a dimension that FacetsConfig declares hierarchical but no document in the reader has.

TestSsdvHierarchicalDimNotIndexed.java (JUnit test)47 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.sortedset;

import org.apache.lucene.document.Document;
import org.apache.lucene.facet.Facets;
import org.apache.lucene.facet.FacetsConfig;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSsdvHierarchicalDimNotIndexed extends LuceneTestCase {

  public void testGetAllChildrenOfUnseenHierarchicalDim() throws Exception {
    FacetsConfig config = new FacetsConfig();
    config.setHierarchical("category", true);
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, newIndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new SortedSetDocValuesFacetField("author", "bob"));
        w.addDocument(config.build(doc));
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Facets facets =
            new SortedSetDocValuesFacetCounts(
                new DefaultSortedSetDocValuesReaderState(reader, config));
        // expected null (Facets javadoc: "dimension was never seen"); actual NullPointerException
        assertNull(facets.getAllChildren("category"));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-527/, 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.sortedset.TestSsdvHierarchicalDimNotIndexed
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected null (Facets javadoc: "dimension was never seen"); actual NullPointerException: Cannot read field "dimStartOrd" because "dimTree" is null, with -ea and -da.

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

More files, environment, full output38 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.NullPointerException: Cannot read field "dimStartOrd" because "dimTree" is null
       org.apache.lucene.facet.sortedset.AbstractSortedSetDocValueFacetCounts.prepareChildIteration(AbstractSortedSetDocValueFacetCounts.java:257)
       org.apache.lucene.facet.sortedset.AbstractSortedSetDocValueFacetCounts.getAllChildren(AbstractSortedSetDocValueFacetCounts.java:68)
       org.apache.lucene.facet.sortedset.SortedSetDocValuesFacetCounts.getAllChildren(SortedSetDocValuesFacetCounts.java:57)
       org.apache.lucene.facet.sortedset.TestSsdvHierarchicalDimNotIndexed.testGetAllChildrenOfUnseenHierarchicalDim(TestSsdvHierarchicalDimNotIndexed.java:43)

Why it looks unintended

  • The Facets javadoc: both methods return null "if this dimension was never seen"; LUCENE-9971 (9.0.0) made SSDV faceting do so "as per Javadoc spec".
  • For the same state the flat branch, a path below the dimension, getSpecificValue (-1) and taxonomy faceting all say "never seen".
  • 9.2.0's getTopChildren returned null; #915, a cleanup that "doesn't modify the API", added the throw.
The full argument341 words
  • The contract. Facets.java:33-37 (getAllChildren) and :39-44 (getTopChildren): "Returns null if the specified path doesn't exist or if this dimension was never seen." Nothing restricts this to flat dimensions.
  • The precedent. LUCENE-9971 (#149, 9.0.0), CHANGES.txt:4974-4976: "Requesting facet counts for unseen dimensions in SortedSetDocValueFacetCounts and ConcurrentSortedSetDocValueFacetCounts now returns null / -1 instead of throwing IllegalArgumentException as per Javadoc spec in Facets." In that PR's review (2021-05-25) @gsmiller wrote "I think it's reasonable to return null (it's much like a Java Map)", and in its discussion (2021-05-26) "+1 for making the change you proposed, particularly since it's consistent with the Javadoc".
  • Every sibling returns null for the same state (executed): the flat branch (AbstractSortedSetDocValueFacetCounts.java:269-273), the same hierarchical dimension with a path (lookupTerm < 0), getSpecificValue (-1; Facets.java:46-50 "Returns -1 if this path doesn't exist") and taxonomy faceting (FastTaxonomyFacetCounts) with the same FacetsConfig.
  • History. Up to 9.2.0 the hierarchical code looked the dimension up with dv.lookupTerm(...) first and returned null for pathOrd < 0; it read getDimTree only after that. #915 (LUCENE-10585) replaced the lookup with the dimStartOrd shortcut; its author wrote that the change "is purely meant to remove some code duplication and make some very minor optimizations, and doesn't modify the API", the PR says "No new tests were written", and CHANGES calls it a "Facet module code cleanup" (CHANGES.txt:4067-4068). When hierarchical SSDV facets were added (#509, 9.1.0), a dimTree == null check was removed as unreachable ("If we reach this point, it means the path was indexed. Shouldn't there always be a dim tree then?"), which was right while the lookup ran first.
  • Why tests missed it. TestSortedSetDocValuesFacets#testHierarchicalNonExistentDimension configures fizz as hierarchical but asks for "non-existent dimension", which is flat by default, and asserts null (comment: "make sure the result is null (and no exception was thrown)") (:1779-1815).
  • The trigger is legal. Public API only, and every document is built with the same FacetsConfig the search uses, as FacetsConfig's javadoc asks (FacetsConfig.java:53-55).
The devil's-advocate check237 words

The strongest case for "intended or misuse", built by the intended-behaviour verifier, a review by a model from another family (OpenAI GPT-5.6) and the adjudication: the facet classes are @lucene.experimental, SortedSetDocValuesReaderState#getDimTree is not documented as non-null, a dimension that is configured but has no documents could be called a configuration/index mismatch, and the upstream never-seen tests only use an unconfigured name.

A narrower precedent a committer may raise: in the same #149 review @gsmiller argued for keeping an IllegalArgumentException when a dimension configured in FacetsConfig is looked up in the wrong index field ("hey, we actually know about this dimension but you're looking in the wrong place!"), and taxonomy faceting does that today (TaxonomyFacets#verifyDim, TaxonomyFacets.java:213-235). It does not cover this case: the repro reads the dimension's own (default) index field, the dimension simply has no document, and that review argued for a deliberate, descriptive exception for a misconfiguration, not for an NPE on a legal state.

Why it fails: experimental means the API may change, not that it may throw an NPE against its own javadoc; the javadoc covers exactly the "never seen" case; every document was built with the same config, as required; the flat branch, the path branch, getSpecificValue and taxonomy faceting all treat the state as never seen; 9.2.0 returned null; and #915 was declared API-neutral. Every reviewer rated the intended argument weak or none. Verdict: not intended, a regression.

Suggested fix

Recommended Return null when the dimension has no tree

In prepareChildIteration, return null when state.getDimTree(dim) is null, with the flat branch's own comment "means dimension was never indexed" (4 lines). The repro and a new test in TestSortedSetDocValuesFacets then pass with assertions on and off, and so does the whole facet module suite (312 tests).

     if (dimConfig.hierarchical) {
       DimTree dimTree = state.getDimTree(dim);
+      if (dimTree == null) {
+        // means dimension was never indexed
+        return null;
+      }
       if (path.length > 0) {
All options and trade-offs3 options ยท 630 words

One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF), together with a new upstream-style test in TestSortedSetDocValuesFacets; option B was run on 9.3.0 by the version-history runs; option C was not run.

Return null when the dimension has no tree (validated by the checks) (recommended)

Four lines in AbstractSortedSetDocValueFacetCounts#prepareChildIteration, right after getDimTree. The shortcut stays and the term lookup that #915 removed does not come back (the cost of the added null check was not benchmarked); the root of an unindexed hierarchical dimension returns null, as in 9.2.0, and a path request for it returns the null it already returned, one step earlier. A second diff adds testConfiguredHierarchicalDimensionNeverIndexed to TestSortedSetDocValuesFacets, next to testHierarchicalNonExistentPath: root and path requests return null and getSpecificValue returns -1, through getAllFacets (sequential or concurrent at random).

     if (dimConfig.hierarchical) {
       DimTree dimTree = state.getDimTree(dim);
+      if (dimTree == null) {
+        // means dimension was never indexed
+        return null;
+      }
       if (path.length > 0) {

Trade-offs

  • None for results: it returns what the javadoc, the flat branch and 9.2.0 return.
  • It also turns any other route to a missing tree into null. LFV-324 (the SSDV tree is built from ord order and can drop a dimension that is present) may reach the same line for a dropped dimension (inferred, not executed); this check would turn that crash into null but does not fix LFV-324, which belongs in the tree construction.
  • Placement: the check sits before the path/no-path split. With the default reader state getDimTree is null only when the dimension has no indexed term, where a path request already returned null; but a custom SortedSetDocValuesReaderState that returns no tree for a dimension with indexed terms, or a tree dropped by LFV-324, would now get null for a path request too instead of an NPE at dimTree.iterator(pathOrd) (:263). To keep that loud, put the check only in the empty-path branch, or use option B. Neither variant was run.

What the checks validated

  • Stock main a9779ef3c193: the repro fails 4 of 5 and the new upstream-style test fails (6 tests, 5 failed), with assertions on and off.
  • With the fix (gradle, scratch worktree at a9779ef3c193, seed DEADBEEF): the repro and the new test pass, 6 of 6, with -Ptests.asserts=true and =false.
  • With the fix, the whole lucene/facet test suite, including both new tests: 312 tests in 40 classes, 0 failures, 8 skipped; checkGoogleJavaFormat and ecjLint of the facet module pass.
  • Classpath runner, the fix shadowed onto the stock main jars: the repro passes 5 of 5 and the independent reproducer's test 8 of 8. In the hunt, the finder's fix (the same check) turned the finder harness's 15 NullPointerException entries into 0, and with the hunt unit's fix sketches shadowed together 75 upstream facet tests passed (TestSortedSetDocValuesFacets, TestDrillSideways, TestParallelDrillSideways, TestMultipleIndexFields and three taxonomy suites, seeds DEADBEEF and 1234ABCD).
  • Not run: a benchmark (the change adds one null check per root request).

Restore the term lookup for the root too (run on 9.3.0 only)

Drop the shortcut: resolve pathOrd with dv.lookupTerm(pathToString(dim, path)) for every request, return null for pathOrd < 0, then read getDimTree, as 9.2.0 did. The version-history runs's one-hunk revert on 9.3.0 is this option, and both repros pass with it.

Trade-offs

One extra term lookup per root request, the cost #915 wanted to save; not run on main.

Make getDimTree never return null (not run)

Return an empty DimTree for a hierarchical dimension with no indexed term.

Trade-offs

Changes a public abstract method of SortedSetDocValuesReaderState that custom implementations override, and an empty tree needs care so that the root is not reported as an existing path; more change than the bug needs.

Downstream note

A read-only GitHub code search (default branches only, best effort) found no use of these SSDV facet classes in Elasticsearch, OpenSearch or Solr; nothing was tested downstream. The clearest known audience is custom applications on the facet module; one whose getDefaultDimConfig makes dimensions hierarchical gets the exception for any unindexed dimension name a user asks for.

How it was found

Randomized model-based metamorphic testing of the Facets API: a reference written from the javadoc ran against the real classes on random facet schemas and tiny indexes. Its exception bucket held 15 of these NullPointerExceptions in 500 iterations, all where a hierarchical SSDV dimension had no document, and none with the null check added.

The method in full190 words

Method: randomized model-based metamorphic testing (non-formal tier), in a hunt unit on the facet API.

  • A written specification of the five Facets methods, taken from their javadoc (for example: getTopChildren and getAllChildren return null for a dimension that was never seen), served as an executable reference.
  • The harness built random facet schemas (0-3 count dimensions, flat or hierarchical of depth 1-3, single- or multi-valued, plus association dimensions) over indexes of at most 40 documents in 1-4 segments with random deletions, and compared taxonomy, SSDV and string-value faceting, MultiFacets and TaxonomyFacetLabels, sequential and concurrent, against the reference.
  • In 500 iterations with seed 11 its exception buckets for SSDV faceting held 15 NullPointerException entries, all with the dimTree message and all in schemas where a hierarchical SSDV dimension had no document; with the one null check shadowed onto the jar they went to 0. The case was then reduced to a public-API JUnit.
  • Found 2026-10-26 on main ea57360912ed; then checked by an independent reproducer, an intended-behaviour devil's advocate, read-only reviews by a model from another family (OpenAI GPT-5.6), an adjudication, a history, prior-report and security follow-up, and a credibility audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26) in 150 tool queries over GitHub issues, PRs and commits, the mailing lists, the JIRA archive, Elasticsearch, OpenSearch, Solr and the web, plus 19 manual checks, including the changed files of all 409 open PRs. LUCENE-9971 fixed the same symptom for flat dimensions in 9.0.0.

Searches4 searches

Verdict new: no report by anyone. Keyword search cannot prove absence; GitHub issue search does not index code in PR diffs, and private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; Elasticsearch, OpenSearch, Solr; the web2026-10-26four runs of the hunt's prior-report tool, 150 queries: 54 GitHub issue/PR (including label module:facet), 9 commit, 6 downstream, 60 mailing-list, 15 JIRA-archive, 6 webno report; related only: #149 / #11010, #509, #915 / #11621, #914 / #11586, #983, #747 / #11503
GitHub-wide code identifiers; all 409 open apache/lucene PRs2026-10-26searches for dimStartOrd, getDimTree, prepareChildIteration and AbstractSortedSetDocValueFacetCounts; a scan of the changed-file lists of every open PR, patches read for those touching the fault-site filesonly the PRs that wrote the code; open #12862, #16847 and #11780 touch neighbouring files, not prepareChildIteration or getDimTree
discussions of the PRs and JIRA issues that created or edited the code2026-10-26read in full: #149, #509, #747, #777, #914, #915, #929, #983 and LUCENE-9971, -10550, -10584, -10585no mention of a configured hierarchical dimension without documents; #509 removed a then-unreachable dimTree null check
upstream main and branch_10x2026-10-26file contents through the read-only APIthe unguarded line is still there (main line 257, branch_10x line 264): not fixed

History

Introduced by #915 (LUCENE-10585, 8db1e41fc07, 2022-05-29), first released in 9.3.0. Executed: getTopChildren returns null in 9.1.0 and 9.2.0 (getAllChildren arrived in 9.3.0); 9.3.0, 9.10.0, 9.11.0, 9.12.3, 10.0.0, 10.5.2 and main throw. A commit-level bracket on branch_9x and a one-hunk revert on 9.3.0 confirm the cause. Not fixed upstream.

Versions tested10 rows

Introduced by 8db1e41fc072 on main, "LUCENE-10585: Scrub copy/paste code in the facets module and attempt to simplify a bit (#915)" (@gsmiller, 2022-05-29), and its branch_9x backport 075f5103e9ac the same day, which shipped first, in 9.3.0. It replaced the null-safe lookupTerm of the hierarchical branch with pathOrd = dimTree.dimStartOrd for a request without a path. #914 (LUCENE-10550, 9.3.0) copied the shortcut into the new getAllChildren, and #983 (9.3.0) moved both copies into prepareChildIteration, which is why git blame on main names #983.

Since 9.11.0 (#12408, backported as #13300) SortedSetDocValuesFacetCounts#getTopChildren with zero hits returns null before it reaches the shortcut; every other entry point still throws. The unguarded line is present at every release tag from 9.3.0 to 10.5.2 (read).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe repro fails 4 of 5 through gradle with assertions on and off, and through the classpath runner with -ea and -da
main ea57360912ed affectedthe finder's, the independent reproducer's and the history tests fail, -ea and -da
10.0.0, 10.5.2 affectedhistory test 3 of 4 checks fail (zero hits returns null); the full repro fails 4 of 5 on 10.5.2, also -da
9.11.0, 9.12.3 affectedhistory test 3 of 4 checks fail; 9.12.3 also fails the full repro
9.3.0, 9.10.0 first releaseall 4 history checks fail (zero hits included); 9.3.0 with -ea and -da
branch_9x 075f5103e9a (#915 backport) vs parent eecf8ea63b9 bisectthe 9.2.0 jars with the facet module compiled from the commit fail 4 of 4; compiled from the parent they pass
9.3.0 with only the shortcut reverted not affectedboth repros pass
9.1.0, 9.2.0 not affectedgetTopChildren returns null, as documented (9.2.0 with -ea and -da); getAllChildren does not exist before 9.3.0 (LUCENE-10550), so the full repro does not compile
9.0.0 no APIno hierarchical SSDV facets: the SortedSetDocValuesFacetCounts javadoc says it "does not support hierarchical facets" (LUCENE-10250 added them in 9.1.0); the history test does not compile
fix (scratch worktree at a9779ef3c193) not affectedthe repro and the new test pass 6 of 6 with assertions on and off; the whole facet module suite passes (312 tests, 0 failures)

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing; read-only reviews by a model from another family (OpenAI GPT-5.6) rated it real; an adjudication and a credibility audit re-ran it with new seeds; this doc's gradle runs reproduce it on a9779ef3c193 and validate the fix.

Checks and verdicts391 words

Every executing check wrote or re-ran its own JUnit on stock main; every control passed, and every fix check passed. The cross-model reviews could not run Java, so their executed evidence is separate re-runs.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateMetamorphic harness, then a public-API JUnit: 4 of 5 fail on main (-ea, -da, 10 of 10 iterations), 10.5.2, 9.12.3 and 9.3.0; passes on 9.2.0 and 9.1.0 (getTopChildren-only test); the fix passes.
cross-model review, plain (OpenAI GPT-5.6, read-only) real, highInputs in contract, intended argument none, root cause agrees, fix fixes it. The refute-lens review gave no verdict in two attempts (the model stopped the turn).
independent reproducer realOwn JUnit from the claim only: 6 of 8 fail on main (-ea, -da), 10.5.2, 9.12.3 and 9.3.0, both controls pass; added the delete-plus-merge and zero-hit getAllChildren triggers; on 9.2.0 its getTopChildren-only variant passes 7 of 7 (the full test does not compile there); the fix makes it 8 of 8.
cross-model review, intended lens (OpenAI GPT-5.6, read-only) real, high; intended argument weakRated the intended argument (experimental API, getDimTree not documented as non-null, callers could enumerate the indexed dimensions) weak, against the javadoc, the flat sibling, taxonomy faceting and LUCENE-9971; a separate run re-ran the finder's JUnit (4 of 5 fail, -ea and -da).
intended-behaviour devil's advocate real, high (impact modest)Re-ran the finder's JUnit on main and 10.5.2; its consistency test shows taxonomy faceting, the path branch and getSpecificValue treat the state as absent; no won't-fix statement in the PRs, CHANGES or MIGRATE.
adjudication confirmed, highRe-ran the independent reproduction's JUnit with seed C0FFEE01 (-ea and -da: 6 of 8 fail, controls pass).
Follow-up and credibility audit new; not security-sensitive; keepHistory bisected to the #915 backport; 150 prior-report queries; 6 executed security probes; re-runs with fresh seeds (finder 4 of 5 fail with -ea and -da, 5 of 5 pass with the fix; the independent reproduction 6 of 8 fail).
this doc's runs reproduces; fix validatedGradle on a9779ef3c193: stock 6 tests, 5 failed (assertions on and off); with the fix 6 of 6 pass, and the facet module suite passes (312 tests, 0 failures); the classpath runner reproduces it on main, 10.5.2 and 9.3.0 and passes it with the fix shadowed.

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

Finding record

LFV-527, snapshot of 2026-11-06

Registry title
SortedSetDocValues faceting throws NullPointerException from getTopChildren/getAllChildren for a dimension that is configured hierarchical but has no document in the reader, instead of returning null as the Facets javadoc says (regression since 9.3.0)
Area
facet / SortedSetDocValues faceting (AbstractSortedSetDocValueFacetCounts#prepareChildIteration, shared by SortedSetDocValuesFacetCounts and ConcurrentSortedSetDocValuesFacetCounts)
Priority
Medium, rank 232: Hierarchical SortedSetDocValues facets throw NPE for a configured dim absent from the reader, a deterministic loud regression since 9.3 on sharded or fresh indexes, so it follows LFV-526.
Impact class
crash
Impact note
Applications that use SortedSetDocValues facets with a dimension declared hierarchical in FacetsConfig (a non-default setting) and ask for the root of that dimension, getTopChildren(n, dim) or getAllChildren(dim), on a reader in which no document has it (the dimension is configured before its first document, a shard, tenant or filtered index lacks it, or all its documents were deleted and merged away) get a NullPointerException instead of null, also through DrillSideways results whose getAllDims calls getTopChildren per drill-down dim; default codec, IndexWriterConfig and searcher, tiny indexes, -ea and -da, every release from 9.3.0 to 10.5.2 and main; no wrong results, no index damage, and path requests, flat dimensions, getSpecificValue, getAllDims and getTopDims of the plain counts are unaffected.
Summary
You declare a facet dimension as hierarchical (FacetsConfig.setHierarchical("category", true)), index SortedSetDocValues facets in which no document has "category" yet, and call getTopChildren(10, "category") or getAllChildren("category") on SortedSetDocValuesFacetCounts or ConcurrentSortedSetDocValuesFacetCounts, expecting null, because the Facets javadoc says both methods return null 'if this dimension was never seen' and the same call for a flat dimension does. Instead both throw NullPointerException: Cannot read field "dimStartOrd" because "dimTree" is null (getTopChildren when the query has hits, always with the count-all constructor and the concurrent class; getAllChildren always). The cause is a shortcut added in 9.3.0 to AbstractSortedSetDocValueFacetCounts#prepareChildIteration, which reads dimTree.dimStartOrd for an empty path without checking that state.getDimTree(dim) is non-null, while the flat branch and the path branch next to it return null for the same case.
Introduced
8db1e41fc072920dc1c6554f5859d4dd623dace6 (LUCENE-10585, apache/lucene#915 'Scrub copy/paste code in the facets module and attempt to simplify a bit', by @gsmiller; branch_9x backport 075f5103e9ac7f344d11e4b3ad3699a1f2be9f66, the line that shipped first) [exec: the facet module compiled at the backport 075f5103e9a fails the history test 4 of 7 with the same NPE, at its parent eecf8ea63b9 it passes 7 of 7; reverting only the shortcut on 9.3.0 makes both repros pass]. It replaced the null-safe dv.lookupTerm(pathToString(dim, path)) of the hierarchical branch with 'pathOrd = dimTree.dimStartOrd' for an empty path ('a little more efficient to just look up the dim'), ahead of the 'pathOrd < 0 -> null' check. LUCENE-10550 (#914, 9.3.0) copied the shortcut into the new getAllChildren and #983 (9.3.0) moved both copies into prepareChildIteration (git blame on main points at that move). In the review of LUCENE-10250 (#509, 9.1.0) a dimTree == null check had been removed as unreachable, which was correct while the term lookup ran first. (2022-05-29 (8db1e41fc07 on main and the branch_9x backport 075f5103e9a))
First affected version
9.3.0 [exec] (tagged 2022-07-28, first release with the backport); not affected: 9.2.0 and 9.1.0 return null [exec, -ea and -da on 9.2.0]; 9.0.0 and earlier have no hierarchical SortedSetDocValues facets [exec: the probe does not compile]; on the main line the commit is first released in 10.0.0
Affected versions
every release from 9.3.0 to 9.12.3 and from 10.0.0 to 10.5.2, plus main ea57360912ed; never fixed. [exec] 9.3.0, 9.10.0, 9.11.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da on 9.3.0, 10.5.2 and main); [read] the unguarded 'pathOrd = dimTree.dimStartOrd;' is present at every 9.x/10.x tag from 9.3.0 to 10.5.2 and on upstream main (line 257) and branch_10x (line 264) on 2026-10-26. Since 9.11.0 (#12408, backport #13300) getTopChildren on SortedSetDocValuesFacetCounts returns null when the query has no hits (9.10.0 still throws there [exec]); getAllChildren, the count-all constructor and ConcurrentSortedSetDocValuesFacetCounts (whose hasCounts() is always true) still throw
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, verdict new): four scripted prior-report searches, 150 queries (54 GitHub issue/PR on apache/lucene incl. label module:facet, 9 commit, 6 over Elasticsearch, OpenSearch and Solr, 60 mailing-list over dev@, java-user@, issues@ and commits@, 15 JIRA-archive, 6 web; one JQL error re-run correctly) and 19 manual checks (GitHub-wide searches for dimStartOrd, getDimTree, prepareChildIteration and AbstractSortedSetDocValueFacetCounts, whose only hits are the PRs that wrote the code; the changed-file lists of all 409 open apache/lucene PRs; full comments of the related PRs and JIRA issues; upstream main and branch_10x contents). Related, not this bug: #149 / #11010 (LUCENE-9971, 9.0.0: SSDV must return null for an unseen dim, fixed for flat dims before hierarchical SSDV existed; the contract precedent); #509 (LUCENE-10250, 9.1.0: hierarchical SSDV, review thread r779908272 removed a then-unreachable dimTree == null check); #915 / #11621 (LUCENE-10585, the regressing cleanup), #914 / #11586 (LUCENE-10550 getAllChildren) and #983 (refactor); #747 / #11503 (a different NPE, topN = 0); open PRs #12862, #16847 and #11780 touch neighbouring files but not prepareChildIteration or getDimTree. Not fixed upstream.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the compact JUnit and a new upstream-style test through gradle in a clean worktree, stock and with the fix, assertions on and off, and the whole lucene/facet test suite with the fix (2026-10-27); the same JUnit through the hunt's classpath runner on main (-ea, -da, fix shadowed), 10.5.2 and 9.3.0, and the independent reproducer's JUnit on main (stock and fixed). Hunt runs on main ea57360912ed, the branch_9x introducing commit and its parent, and nine releases from 9.0.0 to 10.5.2; no commit between ea57360912ed and a9779ef3c193 touches lucene/facet.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/Facets.java:32-44 (at ea57360912ed) (getAllChildren and getTopChildren: 'Returns null if the specified path doesn't exist or if this dimension was never seen'), :46-50 (getSpecificValue: 'Returns -1 if this path doesn't exist')
  • lucene/facet/src/java/org/apache/lucene/facet/sortedset/AbstractSortedSetDocValueFacetCounts.java:55-63 (at ea57360912ed) (getTopChildren checks hasCounts() first), :65-75 (getAllChildren calls prepareChildIteration before hasCounts()), :247-262 (prepareChildIteration, hierarchical branch: dimTree = state.getDimTree(dim); path.length == 0 -> pathOrd = dimTree.dimStartOrd at :257 with no null check; path.length > 0 -> lookupTerm, pathOrd < 0 -> null), :264-273 (flat branch: getOrdRange(dim) == null -> 'means dimension was never indexed' -> null)
  • lucene/facet/src/java/org/apache/lucene/facet/sortedset/DefaultSortedSetDocValuesReaderState.java:381-387 (at ea57360912ed) (getDimTree returns prefixToDimTree.get(dim), null for a dim with no indexed term)
  • lucene/facet/src/java/org/apache/lucene/facet/sortedset/SortedSetDocValuesFacetCounts.java (at ea57360912ed) and sortedset/ConcurrentSortedSetDocValuesFacetCounts.java (both delegate to the shared base class; the concurrent hasCounts() is always true)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/sortedset/AbstractSortedSetDocValueFacetCounts.java:56-63, 66-75, 97-109, 247-285 (252, 254, 256-257, 259-262, 269-273), 291-297
  • lucene/facet/src/java/org/apache/lucene/facet/sortedset/DefaultSortedSetDocValuesReaderState.java:67, 185, 381-387
  • lucene/facet/src/java/org/apache/lucene/facet/sortedset/ConcurrentSortedSetDocValuesFacetCounts.java:80-84; SortedSetDocValuesFacetCounts.java:57, 79-88, 173-187
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/MultiFacets.java:81-88; DrillSideways.java:150-183; FacetsConfig.java:142-160
  • lucene/facet/src/java/org/apache/lucene/facet/sortedset/DefaultSortedSetDocValuesReaderState.java:381-387
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/Facets.java:33-37, 39-44, 46-50
  • lucene/CHANGES.txt:4067-4068, 4974-4976
  • lucene/facet/src/test/org/apache/lucene/facet/sortedset/TestSortedSetDocValuesFacets.java:1747-1777, 1779-1815
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsConfig.java:53-55; sortedset/SortedSetDocValuesReaderState.java:180-181
History
  • lucene/CHANGES.txt:4054 (9.3.0, LUCENE-10550 getAllChildren), :4427 (9.1.0, LUCENE-10250 hierarchical SSDV facets); a Lucene clone releases/lucene/9.0.0 SortedSetDocValuesFacetCounts.java:56

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

Repro files

About the diagrams

It shows the terms of $facets, the reader state, getTopChildren(10, "category") on both indexes, the code of prepareChildIteration and the 9.2.0 code. Every value on screen comes from the probe's logs (main -ea and -da, main with the fix shadowed, 10.5.2, 9.3.0, 9.2.0); the diagram README maps each part to its source, and the alt text was checked against the final image.

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.