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.
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.- You call
config.setHierarchical("category", true)and index one document that has only a flat facet,author=bob. No document has acategoryyet. - You ask for the top categories:
getTopChildren(10, "category")orgetAllChildren("category")onSortedSetDocValuesFacetCounts. You expect null: theFacetsjavadoc returns null "if this dimension was never seen", and the absent flat dimensionpublisherdoes return null. - Instead both throw
NullPointerException: Cannot read field "dimStartOrd" because "dimTree" is null, with assertions on and off, also fromConcurrentSortedSetDocValuesFacetCountsand the count-all constructor: 4 of the repro's 5 tests fail, the control passes. - Why: since 9.3.0 a request without a path reads
state.getDimTree(dim).dimStartOrddirectly, andgetDimTreereturns 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. - The same happens after every document with
categoryis deleted and merged away (the call worked before the delete), and whenDrillSidewaysresults read a drill-down on such a dimension throughgetAllDims.
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.
- SSDV faceting: facet counting over
SortedSetDocValuesFacetFieldvalues, without a taxonomy index.DefaultSortedSetDocValuesReaderStatereads the facet terms of a reader once;SortedSetDocValuesFacetCountsandConcurrentSortedSetDocValuesFacetCountscount them for a query and share the base classAbstractSortedSetDocValueFacetCounts. - Hierarchical dimension:
FacetsConfig#setHierarchical(dim, true)lets a dimension have paths such ascategory/books/fiction. For each such dimension found in the reader, the state builds aDimTree(its first ordinaldimStartOrdplus child and sibling links); a flat dimension gets anOrdRangeinstead. - Root request:
getTopChildren(n, dim)orgetAllChildren(dim)with no path asks for the dimension's top-level children.
getTopChildren(AbstractSortedSetDocValueFacetCounts.java:56-63on maina9779ef3c193) returns null whenhasCounts()is false (:58), else callsgetTopChildrenForPath, which callsprepareChildIteration(:294).getAllChildren(:66-75) callsprepareChildIterationfirst (:68) and checkshasCounts()only afterwards (:73).prepareChildIteration(:247-285), hierarchical branch:DimTree dimTree = state.getDimTree(dim);(:252). With a path it runsdv.lookupTerm(...)(:254) and returns null forpathOrd < 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.DefaultSortedSetDocValuesReaderState#getDimTreereturnsprefixToDimTree.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,getDimTreereturns null and:257throws.- The flat branch handles the same case:
if (ordRange == null) { // means dimension was never indexed return null; }(:269-273). - So
getAllChildren(dim)throws whenever it is called;getTopChildren(n, dim)throws whenhasCounts()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 forConcurrentSortedSetDocValuesFacetCounts, whosehasCounts()returnstrue(ConcurrentSortedSetDocValuesFacetCounts.java:80-84). - Not affected: requests with a path,
getSpecificValue(term lookup, -1), andgetAllDims/getTopDimsof the plain counts, which iterate the indexed dimensions only.
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)orgetAllChildren(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-wideFacetsConfiglacks 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.
DrillSidewaysbuilds oneSortedSetDocValuesFacetCountsper drill-sideways dimension and returns them in aMultiFacets, whosegetAllDimscallsgetTopChildren(topN, dim)on each (MultiFacets.java:81-88). A drill-down on a configured hierarchical dimension that no document has returns its hits (0), thengetAllDims(10)on the result throws (executed onea57360912edand 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
Facetsobject 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()withByteBuffersDirectory,new IndexWriterConfig()(default codec) and a plainIndexSearcher, throws the same NPE with-eaand-da; the JUnit repro, underLuceneTestCase's randomized directory, codec andIndexWriterConfig, failed with every seed run (executed). - Scale. Deterministic, one document is enough, assertions on or off.
- Workaround. Check
state.getDimTree(dim) != nullbefore asking for the root of a hierarchical dimension (the method throwsUnsupportedOperationExceptionfor flat ones).
The test shows that SortedSetDocValuesFacetCounts#getAllChildren throws for a dimension that FacetsConfig declares hierarchical but no document in the reader has.
/*
* 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"));
}
}
}
}
# 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"); actualNullPointerException: Cannot read field "dimStartOrd" because "dimTree" is null, with-eaand-da.
More files, environment, full output38 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- The
Facetsjavadoc: 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
getTopChildrenreturned 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 sameFacetsConfig. - History. Up to 9.2.0 the hierarchical code looked the dimension up with
dv.lookupTerm(...)first and returned null forpathOrd < 0; it readgetDimTreeonly after that. #915 (LUCENE-10585) replaced the lookup with thedimStartOrdshortcut; 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), adimTree == nullcheck 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#testHierarchicalNonExistentDimensionconfiguresfizzas 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
FacetsConfigthe search uses, asFacetsConfig'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.
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.
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
getDimTreeis null only when the dimension has no indexed term, where a path request already returned null; but a customSortedSetDocValuesReaderStatethat 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 atdimTree.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=trueand=false. - With the fix, the whole
lucene/facettest suite, including both new tests: 312 tests in 40 classes, 0 failures, 8 skipped;checkGoogleJavaFormatandecjLintof 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,TestMultipleIndexFieldsand three taxonomy suites, seeds DEADBEEF and 1234ABCD). - Not run: a benchmark (the change adds one null check per root request).
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.
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.
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.
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
Facetsmethods, taken from their javadoc (for example:getTopChildrenandgetAllChildrenreturn 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,
MultiFacetsandTaxonomyFacetLabels, 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
dimTreemessage 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; Elasticsearch, OpenSearch, Solr; the web | 2026-10-26 | four 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 web | no report; related only: #149 / #11010, #509, #915 / #11621, #914 / #11586, #983, #747 / #11503 |
| GitHub-wide code identifiers; all 409 open apache/lucene PRs | 2026-10-26 | searches 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 files | only 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 code | 2026-10-26 | read in full: #149, #509, #747, #777, #914, #915, #929, #983 and LUCENE-9971, -10550, -10584, -10585 | no mention of a configured hierarchical dimension without documents; #509 removed a then-unreachable dimTree null check |
| upstream main and branch_10x | 2026-10-26 | file contents through the read-only API | the unguarded line is still there (main line 257, branch_10x line 264): not fixed |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the repro fails 4 of 5 through gradle with assertions on and off, and through the classpath runner with -ea and -da |
main ea57360912ed | affected | the finder's, the independent reproducer's and the history tests fail, -ea and -da |
| 10.0.0, 10.5.2 | affected | history 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 | affected | history test 3 of 4 checks fail; 9.12.3 also fails the full repro |
| 9.3.0, 9.10.0 | first release | all 4 history checks fail (zero hits included); 9.3.0 with -ea and -da |
branch_9x 075f5103e9a (#915 backport) vs parent eecf8ea63b9 | bisect | the 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 affected | both repros pass |
| 9.1.0, 9.2.0 | not affected | getTopChildren 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 API | no 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 affected | the repro and the new test pass 6 of 6 with assertions on and off; the whole facet module suite passes (312 tests, 0 failures) |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Metamorphic 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, high | Inputs 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 | real | Own 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 weak | Rated 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, high | Re-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; keep | History 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 validated | Gradle 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. |
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 589 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,274 words
- 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 wholelucene/facettest 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 mainea57360912ed, the branch_9x introducing commit and its parent, and nine releases from 9.0.0 to 10.5.2; no commit betweenea57360912edanda9779ef3c193toucheslucene/facet.
- 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-297lucene/facet/src/java/org/apache/lucene/facet/sortedset/DefaultSortedSetDocValuesReaderState.java:67, 185, 381-387lucene/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-160lucene/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-50lucene/CHANGES.txt:4067-4068, 4974-4976lucene/facet/src/test/org/apache/lucene/facet/sortedset/TestSortedSetDocValuesFacets.java:1747-1777, 1779-1815lucene/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
- TestSsdvHierarchicalDimNotIndexed.java (JUnit test):
repro/LFV-527/src/org/apache/lucene/facet/sortedset/TestSsdvHierarchicalDimNotIndexed.java(47 lines)
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.
assets/LFV-527/bug.png(589 KB)
