Document and enforce the AggregationFunction null contract - #19158
Document and enforce the AggregationFunction null contract#19158Jackie-Jiang wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR formalizes and enforces Apache Pinot’s AggregationFunction null-handling contract (null-handling enabled vs disabled), centralizes merge-identity behavior (treating null as the empty-multiset identity) in AggregationFunctionUtils, and updates aggregation implementations and execution/reduction call sites to follow the contract—preventing downstream NPEs in broker result rendering for “all segments pruned / empty intermediate” cases.
Changes:
- Document the null contract on
AggregationFunction, align method signatures/@Nullableannotations, and ensureextractFinalResultresolvesnullintermediate results safely. - Add
AggregationFunctionUtils.merge(..)/mergeFinalResult(..)and route merge call sites through them to consistently treatnullas the merge identity. - Add/expand tests to enforce the contract across aggregation types and cover boolean MV group-by null semantics.
Reviewed changes
Copilot reviewed 82 out of 82 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MultistageGroupByExecutor.java | Use AggregationFunctionUtils.merge* to handle null merge identity in MSQ group-by merge. |
| pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MultistageAggregationExecutor.java | Use AggregationFunctionUtils.merge to handle null merge identity in MSQ aggregation merge. |
| pinot-core/src/test/java/org/apache/pinot/queries/BooleanAggQueriesTest.java | Add MV group-by coverage for BOOL_AND/BOOL_OR under both null-handling modes. |
| pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunctionTest.java | Remove merge-with-null test now that null identity is handled by caller utilities. |
| pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/MinStringAggregationFunctionTest.java | Remove merge-with-null test now that null identity is handled by caller utilities. |
| pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/MaxStringAggregationFunctionTest.java | Remove merge-with-null test now that null identity is handled by caller utilities. |
| pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionNullContractTest.java | New contract test enforcing safe extraction/rendering for empty intermediates and merge identity. |
| pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java | Route intermediate/final merging through AggregationFunctionUtils.merge*. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/VarianceAggregationFunction.java | Align nullability and remove unreachable null-merge branches. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/TimeSeriesAggregationFunction.java | Handle null intermediate result in extractFinalResult. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SumValuesIntegerTupleSketchAggregationFunction.java | Handle null intermediate result in extractFinalResult. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SumPrecisionAggregationFunction.java | Align nullability and remove unreachable null-merge branches. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SumMVAggregationFunction.java | Make star-tree pre-aggregated branch null-aware via fold/skip-null helpers. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SumLongAggregationFunction.java | Align nullability and remove unreachable null-merge branches. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SumIntAggregationFunction.java | Align nullability and remove unreachable null-merge branches. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SumAggregationFunction.java | Align nullability and remove unreachable null-merge branches. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/StUnionAggregationFunction.java | Ensure extractFinalResult resolves empty-multiset (null) safely. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileTDigestAggregationFunction.java | Resolve null intermediate results in extractFinalResult. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunction.java | Remove null-merge branches; resolve null intermediate in extractFinalResult. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileRawTDigestAggregationFunction.java | Avoid NPE by returning null for empty-multiset in serializer wrapper. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileRawKLLMVAggregationFunction.java | Avoid NPE by returning null for empty-multiset in serializer wrapper. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileRawKLLAggregationFunction.java | Avoid NPE by returning null for empty-multiset in serializer wrapper. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileRawEstAggregationFunction.java | Avoid NPE by returning null for empty-multiset in serializer wrapper. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileKLLAggregationFunction.java | Align extraction/merge behavior with null contract (no null merge inputs). |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileEstAggregationFunction.java | Resolve null intermediate results in extractFinalResult. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileAggregationFunction.java | Resolve null intermediate results in extractFinalResult (preserve empty-list sentinel semantics). |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ParentExprMinMaxAggregationFunction.java | Align @Nullable behavior for group-by/final extraction. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunction.java | Resolve null intermediate results in extractFinalResult (preserve empty-map sentinel semantics). |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/MinStringAggregationFunction.java | Remove null-merge branches; align extraction nullability (also contains indentation regression). |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/MinMaxRangeAggregationFunction.java | Align nullability and remove unreachable null-merge branches. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/MinLongAggregationFunction.java | Align nullability and remove unreachable null-merge branches. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/MinAggregationFunction.java | Align nullability and remove unreachable null-merge branches (also contains indentation regression). |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/MaxStringAggregationFunction.java | Remove null-merge branches; align extraction nullability (also contains indentation regression). |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/MaxLongAggregationFunction.java | Align nullability and remove unreachable null-merge branches. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/MaxAggregationFunction.java | Align nullability and remove unreachable null-merge branches (also contains indentation regression). |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/LastWithTimeAggregationFunction.java | Resolve null intermediate results in extractFinalResult. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/IntegerTupleSketchAggregationFunction.java | Remove null-merge branches consistent with centralized merge identity. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/IdSetAggregationFunction.java | Resolve null intermediate results in extractFinalResult (no object to serialize). |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/HistogramAggregationFunction.java | Align group-by extraction nullability and final extraction signature. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/funnel/window/FunnelStepDurationStatsAggregationFunction.java | Align final extraction nullability; remove unreachable null-merge-final branch. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/funnel/window/FunnelMaxStepAggregationFunction.java | Align final extraction nullability. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/funnel/window/FunnelMatchStepAggregationFunction.java | Align final extraction nullability. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/funnel/window/FunnelEventsFunctionEvalAggregationFunction.java | Remove null-merge branches; align extraction nullability. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/funnel/window/FunnelCompleteCountAggregationFunction.java | Align final extraction nullability. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/funnel/window/FunnelBaseAggregationFunction.java | Remove null-merge branches; align extraction nullability. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/funnel/FunnelCountAggregationFunction.java | Remove null-merge branches; align final extraction nullability. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/FrequentStringsSketchAggregationFunction.java | Remove null-merge branches; resolve null intermediate safely in final wrapper. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/FrequentLongsSketchAggregationFunction.java | Remove null-merge branches; resolve null intermediate safely in final wrapper. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/FourthMomentAggregationFunction.java | Align final extraction nullability. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/FirstWithTimeAggregationFunction.java | Resolve null intermediate results in extractFinalResult. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/FastHLLAggregationFunction.java | Resolve null intermediate results for empty-multiset (distinct count = 0). |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctSumMVAggregationFunction.java | Resolve null intermediate results consistently with SV variant. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctSumAggregationFunction.java | Resolve null intermediate results consistently with empty-set behavior. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountULLAggregationFunction.java | Remove null-merge branches per centralized merge identity. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountThetaSketchAggregationFunction.java | Align final extraction nullability annotation usage. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartULLAggregationFunction.java | Align final extraction signature to accept @Nullable intermediate. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLPlusAggregationFunction.java | Remove null-merge branches; make final-result merge non-null per contract. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountMVAggregationFunction.java | Align @Nullable annotation placement with behavior. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountHLLPlusAggregationFunction.java | Align @Nullable annotation placement with behavior. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountHLLAggregationFunction.java | Align @Nullable annotation placement with behavior. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountCPCSketchAggregationFunction.java | Remove null-merge branches; align final extraction annotation placement. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapAggregationFunction.java | Align @Nullable annotation placement with behavior. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountAggregationFunction.java | Align @Nullable annotation placement with behavior. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctAvgMVAggregationFunction.java | Resolve null intermediate results consistently with SV variant. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctAvgAggregationFunction.java | Resolve null intermediate results consistently with empty-set behavior. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/CovarianceAggregationFunction.java | Resolve null intermediate results in final extraction. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/CountMVAggregationFunction.java | Make star-tree pre-aggregated branches null-aware via fold/skip-null helpers. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/CountAggregationFunction.java | Make star-tree pre-aggregated branches null-aware via fold/skip-null helpers. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ChildAggregationFunction.java | Align final extraction signature for @Nullable intermediate. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/BaseBooleanAggregationFunction.java | Skip null rows in MV group-by when null handling enabled; align nullability. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AvgValueIntegerTupleSketchAggregationFunction.java | Handle null intermediate result in extractFinalResult. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AvgAggregationFunction.java | Make serialized-avg aggregation paths null-aware; align nullability and remove null-merge branches. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/array/SumArrayLongAggregationFunction.java | Align extraction nullability for empty-multiset cases. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/array/SumArrayDoubleAggregationFunction.java | Align extraction nullability for empty-multiset cases. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/array/ListAggFunction.java | Remove null-merge branches; resolve null intermediate result in final extraction. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/array/BaseArrayAggFunction.java | Align aggregation/group-by extraction nullability. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AnyValueAggregationFunction.java | Align extraction nullability; remove merge null-branch per centralized merge identity. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionUtils.java | Add centralized merge / mergeFinalResult helpers resolving null as merge identity. |
| pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunction.java | Document/enforce null contract; adjust merge/mergeFinalResult signatures and semantics. |
| pinot-core/src/main/java/org/apache/pinot/core/operator/combine/merger/AggregationResultsBlockMerger.java | Route block merging through AggregationFunctionUtils.merge. |
| pinot-core/src/main/java/org/apache/pinot/core/data/table/SortedRecordsMerger.java | Route record merging through AggregationFunctionUtils.merge. |
| pinot-core/src/main/java/org/apache/pinot/core/data/table/IndexedTable.java | Route intermediate/final record merging through AggregationFunctionUtils.merge*. |
31e0101 to
fdf5676
Compare
fdf5676 to
8c032c1
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #19158 +/- ##
=============================================
- Coverage 66.62% 38.95% -27.68%
+ Complexity 1423 1422 -1
=============================================
Files 3443 3443
Lines 218626 218365 -261
Branches 34792 34721 -71
=============================================
- Hits 145662 85058 -60604
- Misses 61240 125548 +64308
+ Partials 11724 7759 -3965
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:
|
8c032c1 to
17ff99e
Compare
yashmayya
left a comment
There was a problem hiding this comment.
I read the full diff at head (17ff99e). The direction is correct, and the merge centralization is complete rather than partial.
What I confirmed
- All six call sites use the new helpers. I searched every
.merge(and.mergeFinalResult(receiver inmain/. No unconverted caller ofAggregationFunction.mergeis left. The only direct calls that remain are in thePercentileRaw*andDistinctCountRaw*wrappers. Each of those delegates from inside its ownmerge, so both operands are real values. The removal of the null branches from the implementations is therefore safe. - The two multi-stage executor rewrites keep the same behavior. The old
if (x == null) continue;is the identity that the helper now returns. - The multi-value collapse is correct, and it repairs a real fault. For each collapsed class, the dispatch of the parent is a superset of the deleted override. It selects the same method for a multi-value block. On master,
MinMaxRangeMVAggregationFunction.aggregatecalledaggregateMVfor every block. A pre-aggregated star-tree column isBYTES, so that call went togetDoubleValuesMV(). The parent tests forBYTESfirst.AvgMVhad the same fault. - The single-value and multi-value alignment is now complete. Every family whose single-value class takes the option now has a multi-value class that takes it too. The HLL, bitmap and theta families take no option on either side.
DistinctCountMVandDistinctSumMVlost theirmergeFinalResultoverrides safely. Both parents hold an equivalent implementation, so neither function falls back toUnsupportedOperationException.- The NPE is real and reachable.
EmptyResponseUtils.buildEmptyAggregationResultTablecallsextractFinalResult(extractAggregationResult(createAggregationResultHolder()))directly.
The description understates the blast radius
The description says this: when the option is disabled, the seven multi-value functions are unaffected. That holds for the single-stage engine. It does not hold for the multi-stage engine.
AggregateOperator builds every aggregation function with null handling enabled (AggregateOperator.java:321). The constructors of these multi-value functions discarded the flag before, so the final stage never reached the isEmpty() && _nullHandlingEnabled branch. Now it reaches it.
For a multi-stage query with default options, over an input where nothing was aggregated:
| Function | master | this PR |
|---|---|---|
percentileMV(mv, 50) |
-Infinity |
NULL |
distinctSumMV(mv) |
0.0 |
NULL |
distinctAvgMV(mv) |
NaN |
NULL |
The new values are better. But the change is unconditional on that engine, and no query option controls it. Add this to the description, and to the release notes.
The count is also low. The factory threads the option into ten functions, not seven. PERCENTILERAWESTMV, PERCENTILERAWKLLMV and PERCENTILERAWTDIGESTMV change too.
CI is green, so no test covers multi-stage multi-value aggregates over an empty input today.
The regression test is missing
The description says that this NPE reached production. No test covers the path that produced it. EmptyResponseUtilsTest is the natural home. That test also runs ColumnDataType.convert, which is the call that threw. The render() helper in the new test calls toString() only, so it does not reproduce that step.
The rest is in the line comments. Two of them matter. The contract promises an answer that three percentile functions do not give. The change also makes a documented serialization fault newly reachable.
68f1bc9 to
7e21542
Compare
Write the two-mode null contract onto AggregationFunction, settle the null identity of merging in the caller so implementations only ever see real values, fix the raw percentile functions that threw NPE when rendering an empty result, and align @nullable annotations with what each implementation does. Thread the query's null handling option into seven multi-value functions that hard-coded it off, and collapse fifteen multi-value variants onto their single-value implementations, which already dispatch on the block value set. Make extractFinalResult the sole decision point for every function that receives the option, so an untouched accumulator travels as null instead of being substituted away, and extend the contract test harness to probe block value types and feed every input column.
7e21542 to
a9d301f
Compare
Summary
Null handling is a per-query flag, and the two modes place very different requirements on an
AggregationFunction— but that contract was never written down. Implementations drifted apart as a result: some resolved the "nothing was aggregated" case, some threw on it, some carried defensive null branches no caller could reach, and@Nullableannotations disagreed with the code they annotated.This writes the contract onto
AggregationFunctionand brings the implementations in line with it.Behavior change: ten multi-value functions now honour
enableNullHandlingDISTINCT_COUNT_MV,DISTINCT_SUM_MV,DISTINCT_AVG_MV,PERCENTILE_MV,PERCENTILE_EST_MV,PERCENTILE_KLL_MVandPERCENTILE_TDIGEST_MVhard-codednullHandlingEnabled = falsein their constructors — they did not accept the parameter at all, so the query option had no effect on them whatever it was set to.The null-aware machinery was already present and correct in the shared base classes; it was simply switched off. Threading the option through the constructors is the whole fix.
PERCENTILE_RAW_EST_MV,PERCENTILE_RAW_TDIGEST_MVandPERCENTILE_RAW_KLL_MVchange with them. Each one extends or wraps one of the seven, so it reaches the option through the function it delegates to and needed no edit of its own — seven constructors, ten functions whose answers move.For a query with
enableNullHandling=true, these functions now skip null rows instead of folding in the column default, which changes their results.DISTINCT_SUM_MVover an all-null input returnsNULLrather than0, for example. With the option disabled they are unaffected.Fixes an NPE in the broker response path
PERCENTILERAWEST,PERCENTILERAWKLL,PERCENTILERAWTDIGESTand their MV variants wrapped the intermediate result in a serializer (SerializedQuantileDigest/SerializedKLL/SerializedTDigest) without checking it. Those wrappers dereference what they are handed in bothtoString()andcompareTo(), so anullintermediate did not fail at extraction — it failed later, when the broker rendered the value.For the KLL variants this is reachable on the single-stage engine in both modes, because
PercentileKLLAggregationFunction.extractAggregationResultreturns the holder's value directly and that isnullfor an untouched holder. A query whose segments are all pruned hits it.Fixes
PERCENTILETDIGESTansweringNaNwhere the contract saysNULLPercentileTDigestAggregationFunction.extractFinalResulthad no empty-accumulator branch, and itsextractAggregationResultbuilds an empty digest rather than returningnullfor an untouched holder.TDigest.quantile()returnsNaNfor an empty digest, soPERCENTILETDIGESTover an all-null column withenableNullHandling=trueansweredNaNwhilePERCENTILE,PERCENTILEESTandPERCENTILEKLLansweredNULLfor the same query.The branch now matches its three siblings, which already had it.
PERCENTILETDIGESTMVinherits the method, andPERCENTILESMARTTDIGESTgets the same test on its t-digest branch so that it agrees with its own value-list branch — which of the two a query lands in depends only on whether the accumulator crossed the conversion threshold.The test is gated on the null handling option, so the disabled mode is unchanged.
Makes
extractFinalResultthe sole decision point wherever the option is availableSubstituting an empty accumulator in the extraction methods destroys the "nothing was aggregated" signal before
extractFinalResultcan act on it, and forces every later stage to re-derive it with a per-type emptiness probe. Every function that receives the null handling option now returnsnullfrom both extraction methods and renders the disabled-mode value inextractFinalResultinstead:AVG,MIN_MAX_RANGE,VAR_POP/VAR_SAMP/STDDEV_POP/STDDEV_SAMPDISTINCT_COUNT,DISTINCT_SUM,DISTINCT_AVG(via their shared base) andDISTINCT_COUNT_OFF_HEAPPERCENTILE,PERCENTILE_EST,PERCENTILE_TDIGEST,PERCENTILE_SMART_TDIGEST, with the raw and multi-value variants of eachAnswers are unchanged in both modes. Each disabled-mode value was read off what that function's substituted accumulator rendered —
0.0for a distinct sum over an empty set,NaNfor a distinct average,Long.MIN_VALUEfor an emptyQuantileDigest,NaNfor an emptyTDigest. The raw percentile wrappers changed with their delegates rather than after them: they build the empty digest where it is rendered and serialize it, so a raw percentile still emits a serialized empty digest with the option off.Two answers do change, both toward the contract.
VAR_POPansweredNULLfor a group with no rows whileMIN_MAX_RANGEanswered-Infinityfor the identical query, because the two disagreed about which extraction path substitutes — reachable through a filtered aggregation, where one group key space is shared across every aggregation and a group created by one can hold no rows for another. AndDISTINCT_COUNT_OFF_HEAPno longer callsclose()on a shared placeholder set.The functions that never receive the option are left alone. They cannot be conformed separately: without it,
extractFinalResulthas nothing to decide with. That is now recorded as part of the first known deviation rather than as a deviation of its own.The contract
0forSUM,+InfinityforMIN). This is a performance path, and those answers are a backward-compatibility constraint rather than an attempt at SQL conformance.nullintermediate result means nothing was aggregated, andextractFinalResultis the only place that decides what that means for a given function:0for the counting functions,nullfor the value functions.Object-backed accumulators whose type has no identity to render (
MAXSTRING,MINSTRING,ANYVALUE) returnnulleven with the flag off, soextractFinalResulthas to acceptnullin both modes.Settles the merge identity once, in the caller
The "nothing was aggregated" case is the identity of merging and means the same thing for every aggregation, so it does not belong in each implementation.
AggregationFunctionUtils#mergeand#mergeFinalResultresolve anulloperand and only then delegate, and all six call sites route through them.Two of those call sites —
AggregationResultsBlockMergerandSortedRecordsMerger— previously had no null handling at all; two others carried// TODO: Fix itblocks that this removes. With the identity settled in one place,mergeimplementations only ever see two real values, so the null branches inside them are unreachable and have been dropped.Collapses the multi-value variants onto their single-value implementations
Fifteen MV classes were carrying code they did not need:
MaxMV,MinMV,SumMV,AvgMV,MinMaxRangeMV, the percentile MVs, the HLL MVs) overrode all threeaggregate*methods purely to force the multi-value path. Their parents already dispatch onblockValSet.isSingleValue()in all three, and select the same method for a multi-value column, so the overrides only re-derived a decision the parent already makes.DistinctCountMV,DistinctSumMV,DistinctAvgMV) were siblings of their single-value counterparts rather than subclasses, duplicatingextractFinalResult,mergeFinalResultandgetFinalResultColumnType. Those copies had already drifted — the MV ones disagreed with the SV ones on the empty-input answer, which this also fixes.This removes 52 methods. It is not purely cosmetic: the deleted overrides called
aggregateMVunconditionally, which fails on a single-value block set, whereas the parent's dispatch handles it.Also
extractFinalResultresolves the "nothing was aggregated" case across the remaining functions instead of dereferencing it.@Nullableannotations are aligned with what each implementation actually does — added where a method genuinely returnsnull, removed frommergeparameters where the annotation contradicted the contract.BaseBooleanAggregationFunction.aggregateGroupByMVmirrors its single-value counterpart and skips null rows when null handling is enabled.Tests
The test harness now probes block value types until one drives the function, rather than consulting a hard-coded map of which function reads which type, and feeds every input expression the function declares instead of only the first. Single-value
string,bytesandintblocks were added alongside the existinglonganddoubleones.That brought 18 functions into the census that were previously skipped, including
MINSTRING,MAXSTRING,SUMINT,SUMLONG,SUMPRECISION,FIRSTWITHTIME,LASTWITHTIME,COVARPOP,ARRAYAGGandLISTAGG. Fourteen of them turned out to honour the query's null handling option and had never been checked.AggregationFunctionNullContractTestenforces the contract against every aggregation function that can be constructed generically — 95 of the 103AggregationFunctionTypevalues, under both flag settings. The eight it cannot construct are pinned in both directions, so a newly added function cannot drop out of the contract unnoticed and a stale exclusion cannot linger.It asserts that the final result renders, not merely that extraction returned. That distinction is what the raw percentile bug turned on: extraction succeeded and the failure surfaced downstream.
It also covers the behavior change directly.
testNullHandlingOptionReachesEveryFunctionThatHonoursItaggregates an all-null block through every function under both settings and pins the exact set whose answer depends on the option, in both directions: a function that starts honouring it has to be added deliberately, and one that stops is a regression. All ten above were absent from that set before this change.The harness drives single-value
longanddoublecolumns, so functions that read another value type, need a multi-value block, or take more than one input column cannot be aggregated through it. Those are pinned in a second set rather than skipped silently, and the rest of the contract is still asserted against them.BooleanAggQueriesTestgains multi-value group-by coverage forBOOL_AND/BOOL_OR, including groups that separate skipping a null from folding it in as the column default.Known deviations
Four are recorded as a
TODOon the interface rather than addressed here:Several aggregation methods still fold the column default into the aggregate instead of skipping null rows when null handling is enabled: the distinct-count family, the tuple and frequency sketches, the statistical functions, the first/last-with-time functions, and the funnel family.
The multi-stage engine constructs every aggregation function with null handling enabled and never consults the query option, so a query that disables it still gets enabled-mode semantics there.
SUMover an all-pruned query isNULLon the multi-stage engine and0on the single-stage engine. This may well be intended, given the multi-stage engine is the SQL-conformant one — flagging it rather than changing it.With null handling disabled, a
nullreaching the data table is mis-serialized unless the column type isOBJECT: the writer uses the encoding reserved forOBJECTwhatever the column type is, which overruns the narrower fixed-size slot ofSTRING,INTandFLOAT, reads back as a value forLONGandDOUBLE, and reads back as an empty array for array types. Group-by keys already work around it by writing a placeholder alongside a null bitmap; aggregate values need the same treatment.This PR widens that deviation rather than fixing it, deliberately. Eleven functions used to throw on a
nullintermediate result and now returnnullfor it, so on the server-side-final-result path a query that previously failed loudly can now write a data table no reader can interpret. It needs that option and zero aggregated rows, so the path is narrow, but a silent wrong answer is a worse failure than the exception it replaces. Fixing the writer is follow-up work.The object-backed functions substitute an empty accumulator in
extractAggregationResult,extractGroupByResultor both, rather than returningnulland lettingextractFinalResultrender the disabled-mode value. Which path substitutes is inconsistent across the nineteen functions with an object result holder, and sometimes within one, so a function can answer differently depending on whether the query groups. A filtered aggregation makes the difference observable, because one group key space is shared across every aggregation in the query, so a group created by one of them can hold no rows for another. With null handling disabledVARPOPthen answersNULLfor that group whileMINMAXRANGEanswers-Infinity, though their aggregation paths agree.Conforming them means making
extractFinalResultthe sole decision point — both extraction methods returnnullfor an untouched accumulator and the disabled-mode value is rendered there. That is a follow-up rather than part of this PR: it changes what a server puts on the wire for every function that substitutes today, so it needs its own upgrade-ordering review, and the test harness added here has to be extended to reach the sketch functions before the change can be verified rather than asserted.