Add null-aware star-tree index - #19367
Draft
Jackie-Jiang wants to merge 1 commit into
Draft
Conversation
Jackie-Jiang
force-pushed
the
null_aware_star_tree
branch
3 times, most recently
from
August 26, 2026 01:27
1af53e3 to
8d3ac3d
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #19367 +/- ##
=============================================
- Coverage 67.55% 39.36% -28.19%
+ Complexity 1430 1429 -1
=============================================
Files 3486 3487 +1
Lines 224100 224230 +130
Branches 35370 35416 +46
=============================================
- Hits 151392 88277 -63115
- Misses 60678 127941 +67263
+ Partials 12030 8012 -4018
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Jackie-Jiang
requested review from
xiangfu0 and
yashmayya
and
a balanced review from Copilot
August 26, 2026 02:09
Jackie-Jiang
force-pushed
the
null_aware_star_tree
branch
2 times, most recently
from
August 26, 2026 18:57
7513521 to
ff7bc41
Compare
yashmayya
requested changes
Aug 26, 2026
yashmayya
left a comment
Contributor
There was a problem hiding this comment.
@Jackie-Jiang I found some major issues with this implementation.
- Grouping by a null dimension crashes
- I built a segment with d =
{5, null, 7, 7}and m ={10, 20, 30, 40}, added a null-aware star-tree, and ranSELECT d, SUM(m) FROM testTable GROUP BY dwith null handling on:Caused by: java.lang.IndexOutOfBoundsException ...NoDictionarySingleColumnGroupKeyGenerator.generateKeysForBlockNullHandlingEnabled - The cause is an ordering mismatch. The star-tree stores nulls under the reserved id cardinality, which has no dictionary entry. The group key generator reads the whole block of values first, then applies the null bitmap row by row. So the reserved id reaches the dictionary before anything checks whether the row is null, and the read runs off the end of the buffer. Aggregation-only queries survive because they never turn dict ids into values.
- I built a segment with d =
- An always-true filter returns a wrong answer
- Same segment as above,
SUM(m) WHERE d <> 99999returns an incorrect answer when using the star-tree index. StarTreeUtils.extractPredicateEvaluatorsMapdrops the predicate as always true. The column then never reaches the null check, and the star-tree counts the null row.
- Same segment as above,
Introduces a second star-tree variant, selected with the new `nullHandlingEnabled` flag on StarTreeIndexConfig, that pre-aggregates with null-handling-on semantics. The existing variant is unchanged and continues to serve null-handling-off queries. Both can coexist in a segment: the flag is part of the builder config's identity, so they survive dedup and are not reused for one another. Dimensions store nulls under a reserved dictionary id (the column's cardinality), one past the last real id, so null rows form their own tree node instead of folding into the column's default null value. The forward index is widened to cardinality+1 values and StarTreeLoaderUtils recomputes the bit width to match. A null vector is written for dimensions as well as metrics: the reserved id is out of range for the segment dictionary the star-tree shares, so the query side has to recognize nulls from the vector rather than from the stored id. Metrics exclude null values from the pre-aggregation. A group left with no non-null input has no aggregated value to store, so the metric keeps a placeholder in the forward index and its nullness lives in the metric's null vector, the same shape a regular column is stored in. The off-heap builder does the same for its temporary record store: it cannot encode a null in the serialized record, so nullness is held in a per-metric bitmap keyed by doc id, which is stable because sorting permutes an array of doc ids rather than moving records. COUNT is the one aggregator that still answers for an all-null group itself, via ValueAggregator#getAllNullAggregatedValue. It is read back by summing the pre-aggregated column rather than through the null vector, and 0 is exactly the placeholder a null would leave behind, so recording the group would cost a bit per group and buy nothing. Every other aggregator takes the null default, which keeps an all-null group down to a placeholder plus one null-vector bit instead of a serialized empty sketch. COUNT(column) becomes representable as a stored function-column pair so a null-aware tree can pre-aggregate per-column non-null counts. Config-side parsing keeps the previous COUNT__col -> count__* normalization for regular trees, so existing table configs are unaffected. Query routing sends each query to a tree built in the matching mode. A null-handling-on query may still fall back to a regular tree when none of the columns it touches contains a null value, which is the check that already existed for this case and is now shared by both paths. The resolved function-column pairs travel with the project operator through AggregationInfo, because COUNT resolves differently per tree and the executors must read back the same columns that were projected. An old segment has no null.handling.enabled property and reads false, and an old table config defaults the flag to false, so a deployment that does not opt in builds the same trees, picks the same tree and gets the same answers as before.
Jackie-Jiang
force-pushed
the
null_aware_star_tree
branch
from
August 27, 2026 21:08
ff7bc41 to
6712bd0
Compare
Jackie-Jiang
marked this pull request as draft
August 27, 2026 21:09
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a second star-tree variant that pre-aggregates with null-handling-on semantics, selected with a new
nullHandlingEnabledflag onStarTreeIndexConfig. Today a star-tree folds nulls into the column's default value and counts them, which is right for a null-handling-off query and wrong for a null-handling-on one — so a query that enables null handling can only use a star-tree when nothing it touches is actually null, and otherwise drops to a raw scan.Both variants can live in the same segment. The flag is part of the builder config's identity, so they survive dedup and one is never reused for the other.
Depends on #19218: excluding nulls from the pre-aggregation only agrees with a raw scan once the aggregation functions themselves skip null rows, which is now true of all of them.
Dimensions
Nulls are stored under a reserved dictionary id — the column's cardinality, one past the last real id — so null rows form their own tree node instead of collapsing into the default value's node. The forward index is widened to
cardinality + 1values andStarTreeLoaderUtilsrecomputes the bit width to match.A null vector is written for dimensions as well as metrics: the reserved id is out of range for the segment dictionary the star-tree shares, so the query side has to recognise a null from the vector rather than from the stored id.
Metrics
Null values are excluded from the pre-aggregation. A group left with no non-null input has no aggregated value to store, so the metric keeps a placeholder in the forward index and its nullness lives in the metric's null vector — the same shape a regular column is stored in.
COUNTis the one aggregator that still answers for such a group itself, throughValueAggregator#getAllNullAggregatedValue. It is read back by summing the pre-aggregated column rather than through the null vector, and0is exactly the placeholder a null would leave behind, so recording the group in the vector would cost a bit per group and buy nothing. Every other aggregator takes thenulldefault, which keeps an all-null group down to a placeholder plus one null-vector bit rather than a serialized empty sketch, and stopsgetMaxAggregatedValueByteSizesizing the column for one.COUNT(column)becomes representable as a stored function-column pair so a null-aware tree can pre-aggregate per-column non-null counts. Config parsing keeps the previousCOUNT__col→count__*normalisation for regular trees, so existing table configs are unaffected.The off-heap builder
The on-heap builder holds
Records in memory, where a null metric needs no encoding. The off-heap builder serializes every record to a temporary store and could not express one — it dereferenced the missing value while serializing. Nullness is now held in a per-metricRoaringBitmapkeyed by doc id, and the serialized record carries the aggregated type's zero in its place.Keying on the doc id is safe because records never move: sorting permutes an array of doc ids and compares through it, leaving each record where it was written. The record layout is unchanged, so
FixedSizeRecordOffsets' arithmetic still holds.Query routing
Each query is routed to a tree built in the matching mode. A null-handling-on query may still fall back to a regular tree when none of the columns it touches contains a null value — that is the check that already existed for exactly this case, now extracted so both paths share it.
The resolved function-column pairs travel with the project operator through
AggregationInfo, becauseCOUNTresolves differently per tree and the executors must read back the same columns that were projected. Whether a star-tree is in use is read off those pairs being present rather than from a separate flag, so the two cannot disagree.Compatibility
A deployment that does not opt in is unaffected: an old segment has no
null.handling.enabledproperty and readsfalse, an old table config defaults the flag tofalse, and the new mode filter skips nothing when both arefalse. Same trees built, same tree chosen, same answers.The behavioural change is confined to segments that opt into a null-aware tree, where a null-handling-on query now gets the same answer a raw scan would give instead of falling back to one.
Tests
NullAwareStarTreeBuilderTestbuilds a segment whose metric is null for a whole dimension group and covers both that the all-null group survives the build and that the two variants coexist and disagree — a regular tree reports the column default as its minimum and has no null vector, a null-aware one excludes those rows and does.The build mode is a
@DataProviderparameter rather than a random choice.BaseStarTreeV2Testpicks betweenON_HEAPandOFF_HEAPwithRANDOM.nextBoolean()across all its subclasses, which would have made the off-heap gap above a coin-flip flake rather than a failure.NullAwareStarTreeQueriesTestgoes through the query path instead of inspecting what the builder stored, over a segment whose dimension contains nulls — which is where the reserved id has to survive being read back. Both of its tests currently fail, for the two reasons above. Each asserts that fewer documents were scanned than the segment holds, so neither can pass by silently falling back to a raw scan, and a missing group fails rather than reading as0.