Skip to content

Support range index on ingestion-aggregated no-dictionary columns - #19111

Merged
raghavyadav01 merged 7 commits into
apache:masterfrom
raghavyadav01:validate-range-index-on-aggregated-column
Oct 7, 2026
Merged

raghavyadav01 merged 7 commits into
apache:masterfrom
raghavyadav01:validate-range-index-on-aggregated-column

Conversation

@raghavyadav01

@raghavyadav01 raghavyadav01 commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

PR flow

Recover min/max from forward index for ingestion-aggregated no-dictionary columns to enable BitSliced range index.

flowchart TD
  N0["MutableNoDictColumnStatistics #40;F2#41;"]:::stModified
  N1["CompactedNoDictColumnStatistics #40;F1#41;"]:::stModified
  N2["RangeIndexHandler #40;F3#41;"]:::stModified
  N3["IndexCreationContext#46;Builder #40;F4#41;"]:::stModified
  N4["IndexCreationContext#46;Common #40;F4#41;"]:::stModified
  N5["BitSlicedRangeIndexCreator #40;F3#41;"]:::stModified
  N1 -->|"calls super with recoverMinMax#61;false"| N0
  N0 -->|"provides recovered min#47;max to column metadata"| N4
  N2 -->|"calls withMinValue#47;withMaxValue when metadata null"| N3
  N3 -->|"builds context with overridden min#47;max"| N4
  N4 -->|"context#46;getMinValue#47;getMaxValue used by creator"| N5
  classDef stAdded fill:#dafbe1,stroke:#1a7f37,color:#1f2328,stroke-width:2px
  classDef stModified fill:#fff8c5,stroke:#9a6700,color:#1f2328,stroke-width:2px
  classDef stRemoved fill:#ffebe9,stroke:#cf222e,color:#1f2328,stroke-width:2px
  classDef stUnchanged fill:#f6f8fa,stroke:#656d76,color:#1f2328,stroke-width:1px
Loading

AI-generated · Green: added · Yellow: modified · Red: removed · Gray: existing

Diff evidence
  • F1: pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/converter/stats/CompactedNoDictColumnStatistics.java — before · after
  • F2: pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/converter/stats/MutableNoDictColumnStatistics.java — before · after
  • F3: pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/RangeIndexHandler.java — before · after
  • F4: pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/creator/IndexCreationContext.java — before · after
  • Regenerate PR flow

Problem

Configuring a BitSliced (version 2) range index on an ingestion-aggregated metric column causes segments to fail to commit.

Ingestion-time metrics aggregation (aggregationConfigs, or the legacy aggregateMetrics flag) forces its aggregated metric columns to be no-dictionary and skips min/max tracking while the consuming segment is being built — the values mutate in place, so a running min/max would be meaningless (MutableSegmentImpl deliberately skips it). At segment commit time min/max are therefore null, and the BitSliced range index creator reads them for a single-value no-dictionary column:

// RangeIndexType.createIndexCreator
return new BitSlicedRangeIndexCreator(context.getIndexDir(), fieldSpec,
    context.getMinValue(), context.getMaxValue());   // min/max are null here

For INT/LONG columns this throws a NullPointerException at segment build time (((Number) minValue).longValue()), so the affected real-time segments can never commit.

Fix

Rather than reject the combination, recover the value domain at build time:

  1. MutableNoDictColumnStatistics — when the mutable segment reports null min/max, compute them with a single scan of the sealed forward index. Scoped to single-value INT/LONG, the only stored types whose BitSliced range index reads min/max (FLOAT/DOUBLE use the full floating-point ordinal domain and never dereference min/max). The recovered values also flow into the committed segment metadata via the existing metadata path, so aggregated columns now get accurate segment-level min/max (a pruning improvement).

  2. RangeIndexHandler — the index-reload path recomputes min/max from the forward index for segments that were committed before this change (whose metadata min/max are still null), via a new min/max override on IndexCreationContext.Builder. The recovered domain is written into the range index header (which the reader uses for the subtract-min); for such legacy segments the reader still reads a null metadata max and falls back to Long.MAX_VALUE — results stay correct (the RangeBitmap domain is self-contained), only segment-level max pruning is weaker until the segment is rebuilt. Segments sealed after this change carry proper metadata min/max and do not hit that path.

  3. Reverts the interim table-config rejection in RangeIndexType.validate() and the loud-fail guard in BitSlicedRangeIndexCreator (both introduced on this branch, never released).

Notes / discussion

  • The seal-path recovery lives in the statistics layer, which has no visibility into whether a range index is configured, so the one-time scan runs for every aggregated INT/LONG no-dictionary column at commit (comparable in cost to the isSorted() scan already performed there). This is the "overhead we pay to add the range index"; it doubles as a pruning benefit since these columns previously had no segment min/max. Happy to gate it further if preferred.

Testing

  • MutableNoDictColumnStatisticsTest — min/max recovered for single-value INT/LONG when metadata is null; FLOAT/DOUBLE and multi-value columns intentionally stay null.
  • RealtimeSegmentConverterTest — end-to-end: an ingestion-aggregated no-dictionary LONG metric column with a range index seals successfully, its column metadata carries the recovered min/max, and a BitSliced range index is present on the column.
  • IndexCombinationValidationTest — the previously-rejected combinations (both aggregation paths, REALTIME) now validate; version-1, offline, and non-aggregated cases still validate.
  • BitSlicedIndexCreatorTest — reverted to the pre-guard state.

Release Notes

A version-2 (BitSliced) range index on an ingestion-aggregated no-dictionary column is now supported: its min/max value domain is recovered from the sealed forward index at segment build time instead of failing to commit.

@codecov-commenter

codecov-commenter commented Jul 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.48148% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.36%. Comparing base (a3af29e) to head (d2376bb).
⚠️ Report is 47 commits behind head on master.

Files with missing lines Patch % Lines
.../index/loader/invertedindex/RangeIndexHandler.java 77.41% 2 Missing and 5 partials ⚠️
...inot/segment/spi/creator/IndexCreationContext.java 25.00% 4 Missing and 2 partials ⚠️
...converter/stats/MutableNoDictColumnStatistics.java 95.12% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19111      +/-   ##
============================================
+ Coverage     68.07%   68.36%   +0.28%     
- Complexity     1450     1470      +20     
============================================
  Files          3518     3521       +3     
  Lines        228478   229096     +618     
  Branches      36201    36350     +149     
============================================
+ Hits         155540   156624    +1084     
+ Misses        60731    60185     -546     
- Partials      12207    12287      +80     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 68.36% <81.48%> (+0.28%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 68.36% <81.48%> (+0.28%) ⬆️
unittests 68.36% <81.48%> (+0.28%) ⬆️
unittests1 58.11% <11.11%> (+<0.01%) ⬆️
unittests2 40.07% <79.01%> (+0.31%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@raghavyadav01
raghavyadav01 marked this pull request as ready for review July 29, 2026 20:29

@Jackie-Jiang Jackie-Jiang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the correct fix. We should allow building range index, and scan the column to figure out min/max value when they are unavailable.

@raghavyadav01

raghavyadav01 commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @Jackie-Jiang .
Will the following approach be ok ?

I am thinking to compute min/max on demand in MutableNoDictColumnStatistics — when the mutable segment reports null (aggregated columns skip min/max tracking since the value mutates), scan the sealed forward index once (same pass isSorted() already does), before the BitSliced creator is constructed.

This adds one extra O(numDocs) scan per such column at seal time. Will this be OK?

@Jackie-Jiang

Copy link
Copy Markdown
Contributor

I am thinking to compute min/max on demand in MutableNoDictColumnStatistics — when the mutable segment reports null (aggregated columns skip min/max tracking since the value mutates), scan the sealed forward index once (same pass isSorted() already does), before the BitSliced creator is constructed.

This adds one extra O(numDocs) scan per such column at seal time. Will this be OK?

Yes. That is the overhead we need to pay in order to add range index. We should also check how RangeIndexHandler handles null min/max value.

@raghavyadav01 raghavyadav01 changed the title Reject range index on ingestion-aggregated no-dictionary columns Support range index on ingestion-aggregated no-dictionary columns Aug 3, 2026
@raghavyadav01 raghavyadav01 changed the title Support range index on ingestion-aggregated no-dictionary columns [Draft]Support range index on ingestion-aggregated no-dictionary columns Aug 3, 2026
@raghavyadav01
raghavyadav01 force-pushed the validate-range-index-on-aggregated-column branch from 690e9ba to 542f75a Compare August 3, 2026 22:15
@raghavyadav01 raghavyadav01 changed the title [Draft]Support range index on ingestion-aggregated no-dictionary columns Support range index on ingestion-aggregated no-dictionary columns Aug 4, 2026
Ingestion-time metrics aggregation forces its aggregated metric columns
to be no-dictionary and skips min/max tracking during consumption (the
values mutate in place). The BitSliced range index (version 2) creator
subtracts the column min for INT/LONG columns, so with a null value
domain segment commit failed with an NPE.

Instead of rejecting the combination, recover the value domain:

- MutableNoDictColumnStatistics computes min/max with a single scan of
  the sealed forward index when the mutable segment reports null,
  scoped to single-value INT/LONG (the only types whose BitSliced index
  reads min/max; FLOAT/DOUBLE use the full floating-point ordinal
  domain). The recovered values also flow into the committed segment
  metadata, improving pruning.
- RangeIndexHandler recomputes min/max from the forward index on the
  index-reload path for segments committed before this change, via a
  new min/max override on IndexCreationContext.Builder.

Adds unit coverage for min/max recovery and an end-to-end seal-path
test that builds and verifies a range index on an aggregated
no-dictionary column, and flips the validation tests to assert the
combination is now accepted.
Recovering an untracked value domain was a second full scan of the column:
computeSorted() calls getMinValue(), which now scans, and then scanned again
itself. That regressed the single-pass guarantee two existing tests assert
(testIsSortedScansOnce, testUntrackedMinMaxFallsBackToScan), and charged the
extra pass to every ingestion-aggregated INT/LONG column at commit, whether or
not a range index was configured -- BaseSegmentCreator reads min/max for every
column when writing segment metadata.

Fold the two together: the recovery scan walks docs in the order computeSorted()
uses (honouring sortedDocIds) and records non-decreasing order alongside min and
max; computeSorted() reuses that answer instead of rescanning. A segment commit
reads such a column exactly once, as before.
@raghavyadav01
raghavyadav01 force-pushed the validate-range-index-on-aggregated-column branch from 542f75a to 6929ed4 Compare September 28, 2026 23:26
The handler-side recovery had no test: the existing RangeIndexHandlerTest is
mock-based and never builds an index, leaving that path at 22% coverage.

Build a real segment over unsorted, partly negative INT and LONG no-dictionary
columns, strip min/max from its metadata -- which is what a segment committed
with untracked min/max looks like on disk -- then run the handler and query the
resulting BitSliced index. Reverting RangeIndexHandler to its pre-change form
fails both tests with the NPE this PR fixes.

The second test pins the open-ended case: the reader still takes the column max
from metadata and so falls back to Long.MAX_VALUE, but the RangeBitmap domain is
self-contained, so matches stay correct.
The note claimed metadata min/max stay null until the segment is rebuilt. Under
the default columnMinMaxValueGeneratorMode they do not: ColumnMinMaxValueGenerator
runs later in the same SegmentPreProcessor pass and populates them. The weaker
max-pruning caveat applies only when that mode is NONE. Also record that the
default path consequently reads the column twice per preprocess.

@Jackie-Jiang Jackie-Jiang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The min/max recovery covers both realtime segment creation and reloading legacy segments, and I found no blocking correctness or compatibility issues. All 13 reported CI checks pass on this revision.

Left a few optional simplifications below. The main improvement would be caching the effective min/max directly in MutableNoDictColumnStatistics. The explicit INT/LONG loops are reasonable to keep.

///
/// Segments sealed with the recovery in {@code MutableNoDictColumnStatistics} carry proper metadata min/max and do
/// not reach the scan here.
private CombinedInvertedIndexCreator newRangeIndexCreator(ColumnMetadata columnMetadata,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional simplification: both callers already have a forward reader and context, so we could consolidate the two newRangeIndexCreator overloads into one helper. Add !columnMetadata.hasDictionary() to the recovery condition and keep the existing single-value/v2/missing-bounds checks. This removes the duplicated builder/config setup. The helper can also get numDocs from columnMetadata instead of receiving it separately.

Keep plain _minValue/_maxValue fields and the existing _sorted cache: recover
missing bounds for single-value INT/LONG columns once at construction and
record sortedness from the same scan, so the getters are field reads.
The compacted subclass computes its own bounds over valid docs only, and its
sortedDocIds covers just those docs, so the base-class constructor scan would
index past the array. Let subclasses opt out via a protected constructor.
@raghavyadav01
raghavyadav01 merged commit 657d780 into apache:master Oct 7, 2026
17 of 18 checks passed
xiangfu0 added a commit to pinot-contrib/pinot-docs that referenced this pull request Oct 7, 2026
Clarifies range-index support for ingestion-aggregated no-dictionary
metrics and the extra scan when older INT/LONG segments lack min/max
metadata. Follow-up to apache/pinot#19111.

Co-authored-by: Xiang Fu <xiangfu@Xiang-mac-mtv-2.local>
@xiangfu0

xiangfu0 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Docs follow-up merged: pinot-contrib/pinot-docs#1091. The range-index guide now covers ingestion-aggregated no-dictionary metrics and the min/max recovery scan for older INT/LONG segments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants