Skip to content

[UUID 5/8] UUID aggregation, group-by and distinct - #18873

Open
xiangfu0 wants to merge 6 commits into
apache:masterfrom
xiangfu0:uuid-split/05-agg-groupby-distinct
Open

[UUID 5/8] UUID aggregation, group-by and distinct#18873
xiangfu0 wants to merge 6 commits into
apache:masterfrom
xiangfu0:uuid-split/05-agg-groupby-distinct

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Rebased onto master now that #18872 has merged — this PR was stacked on it and carried its 13 files. It is now just the aggregation / group-by / distinct work: 19 files, +967 −77 (was 31 files, +1212).

What

UUID support across the aggregation and grouping paths:

  • Group key generationNoDictionarySingleColumnGroupKeyGenerator and NoDictionaryMultiColumnGroupKeyGenerator handle UUID, backed by a new UuidToIdMap registered in ValueToIdMapFactory. Group keys use UuidKey (two primitive longs) rather than ByteArray.
  • Distinct-count familyDistinctCountBitmap, DistinctCountHLL, DistinctCountHLLPlus, DistinctCountCPCSketch, DistinctCountThetaSketch, DistinctCountULL and IntegerTupleSketch.
  • DISTINCTBytesDistinctTable formats result rows through ColumnDataType#convertAndFormat instead of hard-coding toHexString(), so a UUID column returns canonical UUIDs while BYTES still returns hex.
  • ANY_VALUE and the shared AggregationFunctionUtils plumbing.

A bug this PR would have introduced, and its fix

The original version added case UUID to GroupByDataTableReducer#getConvertedKey as a fall-through to BYTES, returning the raw byte[].

That breaks against #18872, now on master: the other reduce path converts group keys with ColumnDataType#convert, and UUID is the one type whose converted form is not its stored bytes — it yields a java.util.UUID. PredicateRowMatcher casts directly on that, so a byte[] produces ClassCastException: class [B cannot be cast to class java.util.UUID on GROUP BY <uuidCol> ... HAVING <uuidCol> = ....

Fixed by delegating rather than duplicating the knowledge, so the two paths cannot diverge again:

case UUID:
  return columnDataType.convert(dataTable.getBytes(rowId, colId));

Covered by an integration test, not a unit test. The unit-level BaseQueriesTest harness reduces two data tables and so cannot reach this code — which is exactly why the path was uncovered. The new UuidAggregationTest goes through a real broker reduce. I checked the test is actually worth something by reverting the fix: testGroupByUuidColumnWithHaving and testGroupByUuidColumnWithHavingReturningFinalResult both fail with the ClassCastException, and both pass with it.

Review follow-up: UuidToIdMap now casts directly

UuidToIdMap called UuidKey.fromObject(value), which accepts five input types. Both callers already key on UuidKey (via UuidKey.fromBytes), so exactly one type is ever passed and the rest was an instanceof chain per row in the group-by loop. It now casts directly, matching the sibling maps (DoubleToIdMap casts to double) and keeping the input type deterministic — the same property asked for on the equivalent PredicateRowMatcher branch in #18872.

Distinct-count hashes the stored bytes

An earlier revision rendered each UUID as its 36-char canonical string so that DISTINCTCOUNTHLL(uuidCol) would equal DISTINCTCOUNTHLL(CAST(uuidCol AS STRING)). No other logical type provides that guarantee — the scan path switches on the stored type, so TIMESTAMP hashes its raw millis and BOOLEAN its int, and neither matches a CAST to STRING. It also cost a String allocation per row in the aggregation loop.

UUID now hashes its stored 16 bytes, which is what "the stored type is the contract" means for a logical type. I verified byte[] is content-hashed rather than identity-hashed by both HyperLogLog (clearspring MurmurHash) and UltraLogLogUtils.OBJECT_FUNNEL (putBytes) before relying on it.

A minimal guard is still needed at each site: unlike LONG or INT, the stored BYTES type is not a scalar case in this function family — the scan path has no case BYTES at all, and the dictionary path reads BYTES as serialized sketch state. The guard exists only to avoid that misreading:

  • AggregationFunctionUtils — the three duplicated UUID blocks are gone. The BYTES guard now excludes UUID so it falls through to the scalar path, which offers dictionary.get(i) (the stored byte[]), exactly as the scan path does. That file went from +55/-9 to +27/-12.
  • DistinctCountBitmap hashes Arrays.hashCode(bytes).
  • DistinctCountThetaSketch is the one exception: DataType.BYTES there means "serialized sketch" with no scalar-bytes mode, so it must surface a String. It now uses the stored hex rendering, matching every other String-typed UUID boundary (PredicateUtils#getStoredValue, the Bloom filter key).

testUuidDistinctCountHllMatchesStringDistinctCountHll asserted the invariant being dropped; it is replaced by a test pinning the new behaviour so the canonical-string rendering is not quietly reintroduced.

Verification

Full pinot-core reactor on this branch:

Pinot local segment implementations ... SUCCESS [10:29 min]   5136 tests, 0 failures, 0 errors
Pinot Core .......................... SUCCESS [19:43 min]   6894 tests, 0 failures, 0 errors, 1 skipped
BUILD SUCCESS

The per-module counts are the evidence, not the exit code: the run used -Dmaven.test.failure.ignore=true (needed because pinot-core is skipped entirely if an upstream module fails), so BUILD SUCCESS alone would not prove anything. There are zero <<< FAILURE lines in the log.

Integration: UuidAggregationTest + UuidBloomFilterTest — 7 tests, 0 failures.

Part of the #18140 UUID split. Depends on parts 1, 2 and 4, all merged.

@codecov-commenter

codecov-commenter commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 25.86207% with 129 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.63%. Comparing base (3ffb614) to head (aa76511).

Files with missing lines Patch % Lines
.../function/DistinctCountULLAggregationFunction.java 0.00% 26 Missing ⚠️
...ion/DistinctCountCPCSketchAggregationFunction.java 0.00% 24 Missing ⚠️
...nction/DistinctCountBitmapAggregationFunction.java 24.00% 16 Missing and 3 partials ⚠️
...n/DistinctCountThetaSketchAggregationFunction.java 20.83% 18 Missing and 1 partial ⚠️
...ction/DistinctCountHLLPlusAggregationFunction.java 25.00% 15 Missing and 3 partials ⚠️
.../function/DistinctCountHLLAggregationFunction.java 50.00% 10 Missing and 2 partials ⚠️
...aggregation/function/AggregationFunctionUtils.java 53.33% 5 Missing and 2 partials ⚠️
.../core/query/distinct/table/BytesDistinctTable.java 72.72% 3 Missing ⚠️
...not/core/query/reduce/GroupByDataTableReducer.java 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             master   #18873    +/-   ##
==========================================
  Coverage     66.63%   66.63%            
  Complexity     1423     1423            
==========================================
  Files          3443     3443            
  Lines        218663   218798   +135     
  Branches      34801    34847    +46     
==========================================
+ Hits         145705   145802    +97     
- Misses        61230    61265    +35     
- Partials      11728    11731     +3     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 66.63% <25.86%> (+<0.01%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 66.63% <25.86%> (+<0.01%) ⬆️
unittests 66.63% <25.86%> (+<0.01%) ⬆️
unittests1 57.29% <25.86%> (-0.01%) ⬇️
unittests2 38.88% <0.00%> (-0.02%) ⬇️

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.

@xiangfu0
xiangfu0 force-pushed the uuid-split/05-agg-groupby-distinct branch 7 times, most recently from b5e8bc6 to 64cb941 Compare July 7, 2026 07:08
@xiangfu0
xiangfu0 force-pushed the uuid-split/05-agg-groupby-distinct branch 11 times, most recently from ab8f6a0 to adf9c85 Compare July 14, 2026 08:03
@xiangfu0
xiangfu0 force-pushed the uuid-split/05-agg-groupby-distinct branch 4 times, most recently from 5dbed17 to 474fae5 Compare July 27, 2026 00:20
@xiangfu0
xiangfu0 force-pushed the uuid-split/05-agg-groupby-distinct branch from 474fae5 to ac891db Compare July 28, 2026 08:02
@xiangfu0
xiangfu0 force-pushed the uuid-split/05-agg-groupby-distinct branch 9 times, most recently from 0cd8e0c to 56f96cc Compare August 8, 2026 22:49
@xiangfu0
xiangfu0 requested review from Jackie-Jiang and a lite review from Copilot August 9, 2026 00:55
@xiangfu0 xiangfu0 added query Related to query processing aggregation Related to aggregation functions and operations labels Aug 9, 2026

Copilot AI 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.

Pull request overview

Adds end-to-end support for Pinot’s logical UUID type across aggregation/group-by and DISTINCT-style result paths, aligning behavior between stored BYTES (16-byte) representation and canonical UUID semantics in query results and sketch-based distinct-count functions.

Changes:

  • Extend no-dictionary group key generation to treat UUID as a first-class logical type using UuidKey/UuidToIdMap (instead of generic ByteArray) for faster hashing/equality.
  • Fix broker reduce group-key conversion for UUID (GroupByDataTableReducer#getConvertedKey) to return the converted java.util.UUID form instead of raw bytes.
  • Update DISTINCT and distinct-count aggregations (HLL/HLLPlus/ULL/CPC/Theta/Bitmap) plus ANY_VALUE/utility paths to render/hash UUIDs consistently (canonical UUID strings), with new unit + integration coverage.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/UuidAggregationTest.java Integration coverage for UUID GROUP BY, HAVING reduce branches, DISTINCT formatting, and distinct-count behaviors.
pinot-core/src/test/java/org/apache/pinot/core/query/distinct/table/BytesDistinctTableTest.java Verifies DISTINCT result formatting differs correctly for UUID vs BYTES.
pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionaryGroupKeyGeneratorTest.java Extends group key generator tests to include UUID (and additional logical types).
pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountHLLAggregationFunctionTest.java Adds regression + contract tests for UUID hashing behavior in HLL distinct count.
pinot-core/src/main/java/org/apache/pinot/core/query/reduce/GroupByDataTableReducer.java Ensures UUID group keys are converted via ColumnDataType#convert to avoid reducer-path divergence.
pinot-core/src/main/java/org/apache/pinot/core/query/distinct/table/BytesDistinctTable.java Formats DISTINCT output via ColumnDataType#convertAndFormat rather than hard-coded hex.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/utils/ValueToIdMapFactory.java Registers UUID-specific on-the-fly dictionary map.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/utils/UuidToIdMap.java Adds UUID-specialized ValueToIdMap using UuidKey keys and ByteArray storage.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionarySingleColumnGroupKeyGenerator.java Preserves UUID logical type for group-key dispatch and uses UuidKey in hot paths.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionaryMultiColumnGroupKeyGenerator.java Preserves UUID logical type per column and routes raw bytes through UuidKey for on-the-fly dictionaries.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/IntegerTupleSketchAggregationFunction.java Rejects raw UUID inputs with a clearer error instead of sketch-deserialization failures.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountULLAggregationFunction.java Hashes canonical UUID strings for UUID-typed columns to match STRING semantics.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountThetaSketchAggregationFunction.java Converts UUID bytes to canonical strings before updating theta sketches to avoid treating them as serialized sketches.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountHLLPlusAggregationFunction.java Offers canonical UUID strings for UUID columns rather than deserializing raw bytes.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountHLLAggregationFunction.java Offers canonical UUID strings for UUID columns rather than deserializing raw bytes.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountCPCSketchAggregationFunction.java Updates CPC sketch with canonical UUID strings for UUID-typed columns.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapAggregationFunction.java Adds UUID handling by hashing canonical UUID strings into the bitmap path.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AnyValueAggregationFunction.java Ensures UUID columns return ColumnDataType.UUID for canonical rendering.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionUtils.java Handles UUID dictionary entries for distinct-count sketch results by offering canonical UUID strings instead of treating dictionary BYTES as serialized sketches.
Suppressed comments (1)

pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/UuidAggregationTest.java:148

  • This comment block contains lines over the 120-character Checkstyle LineLength limit (config/checkstyle.xml). Please reflow the comment to avoid checkstyle:check failures.
  /// The `serverReturnFinalResult` variant. This is the option that gates the single-data-table branch in
  /// `GroupByDataTableReducer#reduceAndSetResults` (`isServerReturnFinalResult() && dataTables.size() == 1`), so it
  /// covers a different reduce branch from the test above. Verified by reverting the `getConvertedKey` fix: both
  /// this and the plain `GROUP BY ... HAVING` test fail with
  /// `ClassCastException: class [B cannot be cast to class java.util.UUID`, and both pass with it.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

xiangfu0 and others added 4 commits August 9, 2026 02:18
Part 5/8 of splitting apache#18140 (logical UUID type). Rebased onto latest master; stacked on uuid-split/04-sse-predicates-cast.

Downstream references use the UuidKey class merged in apache#18869.
… path

getConvertedKey had `case UUID` falling through to BYTES, returning the raw
byte[]. The other reduce path converts group keys via ColumnDataType#convert,
and UUID is the one type whose converted form is not its stored bytes -- it
yields a java.util.UUID.

PredicateRowMatcher casts that directly (see apache#18872), so the byte[] made
GROUP BY ... HAVING over a UUID column fail with
"ClassCastException: class [B cannot be cast to class java.util.UUID".

Delegating to columnDataType.convert(...) keeps the two paths identical by
construction rather than by duplicated knowledge.

Covered by a new UuidAggregationTest integration test rather than a unit test.
The unit-level BaseQueriesTest harness cannot reach this code, which is why it
was uncovered; a query-level test goes through the real broker reduce. Verified
by reverting the fix: testGroupByUuidColumnWithHaving and
testGroupByUuidColumnWithHavingReturningFinalResult both fail with the
ClassCastException and both pass with it.

The test also covers GROUP BY key rendering, DISTINCT (BytesDistinctTable no
longer hard-codes hex) and DISTINCTCOUNT / DISTINCTCOUNTHLL / DISTINCTCOUNTBITMAP
over a UUID column.
Both callers key on UuidKey already (NoDictionary{Single,Multi}ColumnGroupKey
Generator, via UuidKey.fromBytes), so UuidKey.fromObject accepted five input
types where exactly one is ever passed, and ran an instanceof chain per row in
the group-by loop.

Casting directly matches the sibling maps -- DoubleToIdMap casts to double --
and keeps the input type deterministic, which is what was asked for on the
equivalent PredicateRowMatcher branch in apache#18872.
…cal string

The previous version rendered each UUID as its 36-char canonical string so that
DISTINCTCOUNTHLL(uuidCol) would equal DISTINCTCOUNTHLL(CAST(uuidCol AS STRING)).
No other logical type provides that guarantee: the scan path switches on the
stored type, so TIMESTAMP offers its raw millis and BOOLEAN its int, and neither
matches a CAST to STRING. The UUID rendering was inventing a cross-type
equivalence at the cost of a String allocation per row in the aggregation loop.

UUID now hashes its stored 16 bytes. Verified byte[] is content-hashed rather
than identity-hashed by both HyperLogLog (clearspring MurmurHash) and
UltraLogLogUtils.OBJECT_FUNNEL (putBytes).

A minimal guard is still needed at each site, because unlike LONG or INT the
stored BYTES type is not a scalar case in this family: the scan path has no
`case BYTES`, and the dictionary path reads BYTES as serialized sketch state.

- AggregationFunctionUtils: the three UUID blocks are gone; the BYTES guard now
  excludes UUID so it falls through to the scalar path, which offers
  dictionary.get(i) -- the stored byte[] -- exactly as the scan path does.
- DistinctCountBitmap hashes Arrays.hashCode(bytes).
- DistinctCountThetaSketch cannot take scalar bytes (BYTES there means
  "serialized sketch"), so it surfaces the stored hex rendering instead.

Replaces testUuidDistinctCountHllMatchesStringDistinctCountHll, which asserted
the invariant being dropped, with one pinning the new behaviour.
@xiangfu0
xiangfu0 force-pushed the uuid-split/05-agg-groupby-distinct branch from c18108c to a81af4e Compare August 9, 2026 09:20
Applying the rule that if TIMESTAMP and BIG_DECIMAL need no special handling at
a site, UUID does not either -- their stored types carry them, and UUID's
should too.

Removed:
- NoDictionary{Single,Multi}ColumnGroupKeyGenerator, UuidToIdMap and its
  ValueToIdMapFactory entry. `case BYTES` is already supported there and is
  structurally identical to the UUID branch: same getBytesValuesSV(), same
  loop, same map, differing only in wrapping UuidKey vs ByteArray. Both yield
  ByteArray downstream, which is what ColumnDataType.UUID#convert consumes, so
  this was a pure key-representation optimization -- two primitive longs
  instead of a byte[] wrapper -- with no benchmark to justify 5 files and
  +237/-36. UuidAggregationTest#testGroupByUuidColumn still passes, confirming
  group keys render identically without it.
- AnyValueAggregationFunction. Its switch is on getStoredType(), so TIMESTAMP
  already collapses to LONG (raw millis) and BOOLEAN to INT. UUID collapsing to
  BYTES is the consistent behaviour; the branch made it the odd one out.
- IntegerTupleSketchAggregationFunction. The UUID branch only produced a
  friendlier error message; TIMESTAMP gets no such treatment when it is equally
  unusable there.

Kept, because the same rule shows they are needed:
- BytesDistinctTable: LongDistinctTable and BigDecimalDistinctTable already
  render by ColumnDataType, and MultiColumnDistinctTable already calls
  convertAndFormat. BytesDistinctTable hard-coding toHexString() was the
  outlier; this brings it in line rather than special-casing UUID.
- The distinct-count guards and GroupByDataTableReducer, both verified
  necessary by probe -- BYTES means "serialized sketch" in one and the group
  key must convert in the other.

19 files / +958 -80 -> 12 files / +694 -39.
The previous version added every row holding a ByteArray, then walked the whole
list again in formatRows() to rewrite row[0]. Two traversals and two writes per
row where master did one.

The formatting now happens inline in addRows, with the ColumnDataType passed in
-- the same shape LongDistinctTable already uses for its TIMESTAMP handling.

Not an extra String allocation either way: ColumnDataType#convertAndFormat for
BYTES is ((ByteArray) value).toHexString(), the exact call master made, and
ByteArray#getBytes() returns the internal array without copying.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

aggregation Related to aggregation functions and operations query Related to query processing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants