Jackie-Jiang commented on code in PR #19158:
URL: https://github.com/apache/pinot/pull/19158#discussion_r3739382596


##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunction.java:
##########
@@ -128,13 +211,25 @@ default IntermediateResult 
deserializeIntermediateResult(CustomObject customObje
   ColumnDataType getFinalResultColumnType();
 
   /// Extracts the final result used in the broker response from the given 
intermediate result.
+  ///
+  /// A `null` intermediate result means nothing was aggregated, and this 
method decides what that means for this
+  /// particular aggregation. It is the only place where that per-function 
answer is expressed, so it must never
+  /// propagate the `null` blindly: `COUNT` and the distinct counts return 
`0`, while `SUM`, `MIN`, `MAX`, `AVG` and the
+  /// percentiles return `null`.

Review Comment:
   Added the branch rather than listing them as deviations. `PERCENTILETDIGEST` 
now returns `NULL` over an empty digest with the option on, matching 
`PERCENTILE`, `PERCENTILEEST` and `PERCENTILEKLL`, and `PERCENTILETDIGESTMV` 
inherits it. `PERCENTILESMARTTDIGEST` got the same test on its t-digest branch 
so it agrees with its own value-list branch. Both are gated on the option, so 
the disabled mode is unchanged.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunction.java:
##########
@@ -36,6 +36,61 @@
 /// The implementation should be stateless, and can be shared among multiple 
segments in multiple threads. The result
 /// for each segment should be stored and passed in via the result holder.
 ///
+/// ## Null contract
+///
+/// Null handling is a per-query flag, and the two modes place different 
requirements on an implementation.
+///
+/// ### Null handling disabled
+///
+/// Null values are read as the column's default, so no input value is ever 
null. An implementation keeps a primitive
+/// result holder, performs no null tracking, and needs no null check while 
aggregating.
+///
+/// An untouched accumulator is indistinguishable from one that aggregated to 
the type's identity, so this mode cannot
+/// tell "nothing was aggregated" apart from a real result: the answer is 
whatever the accumulator's initial state
+/// renders to, `0` for `SUM` or `+Infinity` for `MIN`. Where the type has no 
identity to render, the intermediate
+/// result is `null` instead: `MAXSTRING`, `MINSTRING` and `ANYVALUE` are 
object-backed and have no empty value to
+/// return. So [#extractFinalResult] must accept `null` in this mode as well.
+///
+/// ### Null handling enabled
+///
+/// SQL evaluates an aggregate over its **non-null** input values only, and 
separately defines what the result is when
+/// there are none. This mode models those as two distinct things:
+/// - A `null` **intermediate result** means nothing was aggregated, either 
because no row matched or because every

Review Comment:
   Recorded. The paragraph now says `null` is not the only representation, 
names the empty-accumulator forms — set, value list, digest, sketch, map — and 
says why `extractFinalResult` tests for both. It also gives the reason testing 
both is the rule rather than substituting everywhere: `MAXSTRING`, `MINSTRING` 
and `ANYVALUE` have no empty value, so `null` is the only representation always 
available.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunction.java:
##########
@@ -36,6 +36,61 @@
 /// The implementation should be stateless, and can be shared among multiple 
segments in multiple threads. The result
 /// for each segment should be stored and passed in via the result holder.
 ///
+/// ## Null contract
+///
+/// Null handling is a per-query flag, and the two modes place different 
requirements on an implementation.
+///
+/// ### Null handling disabled
+///
+/// Null values are read as the column's default, so no input value is ever 
null. An implementation keeps a primitive
+/// result holder, performs no null tracking, and needs no null check while 
aggregating.
+///
+/// An untouched accumulator is indistinguishable from one that aggregated to 
the type's identity, so this mode cannot
+/// tell "nothing was aggregated" apart from a real result: the answer is 
whatever the accumulator's initial state
+/// renders to, `0` for `SUM` or `+Infinity` for `MIN`. Where the type has no 
identity to render, the intermediate
+/// result is `null` instead: `MAXSTRING`, `MINSTRING` and `ANYVALUE` are 
object-backed and have no empty value to
+/// return. So [#extractFinalResult] must accept `null` in this mode as well.
+///
+/// ### Null handling enabled
+///
+/// SQL evaluates an aggregate over its **non-null** input values only, and 
separately defines what the result is when
+/// there are none. This mode models those as two distinct things:
+/// - A `null` **intermediate result** means nothing was aggregated, either 
because no row matched or because every
+///   matching value was null. It carries no per-function meaning, which is 
what makes it correct for the aggregation
+///   methods to skip null rows outright rather than fold them in.
+/// - [#extractFinalResult] decides what that means for this aggregation, and 
is the only method that does. `COUNT`
+///   and the distinct counts return `0`; `SUM`, `MIN`, `MAX`, `AVG` and the 
percentiles return `null`.
+///
+/// An implementation switches to a nullable result holder only in this mode, 
which keeps the boxing cost on the opt-in
+/// path.
+///
+/// ### Both modes
+///
+/// A `null` operand is the identity of merging, and that carries no 
per-function meaning, so it is resolved once by the
+/// caller rather than in every implementation: merge intermediate results 
through [AggregationFunctionUtils#merge] and
+/// final results through [AggregationFunctionUtils#mergeFinalResult], which 
settle `null` operands before delegating.
+/// [#merge] and [#mergeFinalResult] are therefore handed two real values and 
must not be called with `null`.
+///
+/// TODO: Known deviations from the above.
+///   1. Several aggregation methods do not skip null rows yet when null 
handling is enabled, and still fold the
+///      column's default null value into the aggregate: the distinct-count 
family, the tuple and frequency sketches,
+///      the statistical functions, the first/last-with-time functions, and 
the funnel family.
+///   2. The multi-stage engine constructs every aggregation function with 
null handling enabled and never consults the
+///      query's null handling option, so a query that disables it still gets 
enabled-mode semantics there. The two
+///      engines can therefore answer the same query differently: with null 
handling disabled, `SUM` over a query
+///      whose segments are all pruned is `NULL` on the multi-stage engine and 
`0` on the single-stage engine. This
+///      may be intended, the multi-stage engine being the SQL-conformant one, 
but it means the mode described above
+///      is not actually per-query everywhere.
+///   3. **With null handling disabled, every `null` that reaches the data 
table is mis-serialized unless the column
+///      type is `OBJECT`.** This mode is not meant to produce nulls at all, 
but the object-backed accumulators
+///      described above do, and each one is corrupted on the way out. The 
writer falls back to the encoding
+///      reserved for `OBJECT` whatever the column type is; with null handling 
enabled it instead writes a
+///      placeholder alongside a null bitmap, which is correct for every type. 
The damage varies by type: an array
+///      column has the right width but reads back as an empty array, 
`STRING`, `INT` and `FLOAT` have their
+///      narrower fixed-size slot overrun, and `LONG` and `DOUBLE` read back 
as a value. Both the intermediate and
+///      the final result reach this path, the latter from more functions 
because their reported types are narrower.
+///      Aggregate values need the placeholder and null bitmap that group-by 
keys already use.

Review Comment:
   Stated in the note, as you suggested. Deviation 3 now records that this 
change widens it deliberately, that eleven functions used to throw on a `null` 
intermediate result and now return `null` for it, and that a loud failure 
became a silent one.
   
   I derived the affected list independently rather than copying yours, and got 
a wider one: it also includes `COVAR_POP`, `PERCENTILETDIGEST`, `IDSET` and 
`SUMVALUESINTEGERTUPLESKETCH`. Fixing the writer is follow-up work.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileTDigestAggregationFunction.java:
##########
@@ -330,9 +331,11 @@ public ColumnDataType getFinalResultColumnType() {
     return ColumnDataType.DOUBLE;
   }
 
+  @Nullable
   @Override
-  public Double extractFinalResult(TDigest intermediateResult) {
-    return intermediateResult.quantile(_percentile / 100.0);
+  public Double extractFinalResult(@Nullable TDigest intermediateResult) {

Review Comment:
   Fixed exactly as written. `PERCENTILETDIGESTMV` inherits the method, so it 
changes with it.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunction.java:
##########
@@ -330,8 +323,15 @@ public ColumnDataType getFinalResultColumnType() {
     return ColumnDataType.DOUBLE;
   }
 
+  @Nullable
   @Override
-  public Double extractFinalResult(Object intermediateResult) {
+  public Double extractFinalResult(@Nullable Object intermediateResult) {
+    // A null intermediate result means nothing was aggregated, and a 
percentile of nothing is NULL. An empty value list
+    // is a different thing: it is what an untouched single-stage result 
holder produces, and it keeps its historical
+    // sentinel below so that path is not silently changed.
+    if (intermediateResult == null) {
+      return null;
+    }
     if (intermediateResult instanceof TDigest) {

Review Comment:
   Fixed. The t-digest branch now tests `size() == 0` under the option, so both 
branches answer `NULL` and the answer no longer depends on whether the 
accumulator crossed the conversion threshold.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountSmartHLLPlusAggregationFunction.java:
##########
@@ -338,13 +331,7 @@ public Integer extractFinalResult(@Nullable Object 
intermediateResult) {
   }
 
   @Override
-  public Integer mergeFinalResult(@Nullable Integer finalResult1, @Nullable 
Integer finalResult2) {
-    if (finalResult1 == null) {
-      return finalResult2 == null ? 0 : finalResult2;
-    }
-    if (finalResult2 == null) {
-      return finalResult1;
-    }
+  public Integer mergeFinalResult(Integer finalResult1, Integer finalResult2) {

Review Comment:
   Pushing back on this one.
   
   `mergeFinalResult(null, null)` is not reachable for this function. Its 
`extractFinalResult` returns `0` for a `null` intermediate result, and its 
return is not annotated `@Nullable`, so it never produces a `null` final result 
and no `null` ever enters the merge.
   
   That is the rule the contract in this PR adds: nullability is declared by 
the annotation rather than discovered, and everything downstream may rely on 
it. Restoring the branch would be dead code contradicting the declaration.
   
   If you think the annotation is wrong — that it *can* return `null` — that is 
a different and more interesting bug, and the fix belongs at 
`extractFinalResult` rather than in the merge. Happy to look again if you have 
a path in mind.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to