parthchandra opened a new issue, #7024:
URL: https://github.com/apache/datafusion-comet/issues/7024

   ### What is the problem the feature request solves?
   
   
   ## Summary
   
   Comet does not convert `min`/`max` aggregates whose input is a `StructType`. 
The
   whole aggregate operator (HashAggregate, ObjectHashAggregate, or 
SortAggregate)
   falls back to Spark when any aggregate expression is unsupported, so a single
   `max(struct(...))` keeps the entire aggregation on the JVM.
   
   The native engine already supports this: DataFusion 55.2.0 (the version Comet
   pins) has `MinMaxStructAccumulator` (added upstream in 
apache/datafusion#15667),
   including the grouped-accumulator path. The only thing blocking it is one 
gate on
   the Scala side. This issue is about opening that gate safely, with the right
   guards and parity tests.
   
   ## Motivation
   
   Real workload: `IcebergTimeSeriesDiscoveryTransformJob` (applebot/caspian). 
Its
   aggregation uses the "correlated max-by" struct trick in several places, 
e.g.:
   
   ```scala
   min(when(col(s"${src}FirstSeenMillis") > 0,
     struct(col(s"${src}FirstSeenMillis"),
            col(s"${src}FirstSeenRequestSource"),
            col(s"${src}FirstSeenSourceUrl")))).alias(s"_${src}First")
   
   max(struct(col("lastCrawledTsMillis"), col("isRedirect"), col("finalUrl"),
              col("redirectLastTsMillis"), 
col("authorPublishedTsMillis"))).alias("_crawledMax")
   ```
   
   These are `min`/`max` over `struct(long, string, string, ...)`. They force 
Spark
   to a `SortAggregate` and, more to the point, they are struct-typed min/max, 
so
   Comet declines them and the operator stays on Spark. Supporting struct 
min/max
   lets this aggregation run natively without touching the application.
   
   ## Current behavior (where it is blocked)
   
   `AggSerde.minMaxSupportLevel` in
   `spark/src/main/scala/org/apache/comet/serde/aggregates.scala`:
   
   ```scala
   def minMaxSupportLevel(dt: DataType): SupportLevel = dt match {
     case _: StringType if isStringCollationType(dt) =>
       Unsupported(Some(ComparisonUtils.nonDefaultCollationReason("min/max")))
     case _: StringType | BinaryType => Compatible()
     case _ if minMaxDataTypeSupported(dt) => Compatible()
     case _ => Unsupported(Some(s"Unsupported data type: $dt"))   // StructType 
lands here
   }
   ```
   
   `minMaxUnsupportedReasons` also lists "arrays and structs" as unsupported.
   
   ## Paths audited (what is already covered)
   
   I traced the full conversion path. Everything except the one Scala gate is in
   place.
   
   1. Shared aggregate gate. `QueryPlanSerde.aggExprToProto` (line ~805) is the
      single entry for HashAggregate, ObjectHashAggregate, and SortAggregate; 
all
      three call `getSupportLevel` -> `minMaxSupportLevel`. One change covers 
all
      three operators.
   
   2. Scala serde. `CometMin` / `CometMax` `convert()` serialize the datatype 
with
      `serializeDataType(expr.dataType)`; that already emits StructType (typeId 
16 +
      `StructInfo` with field names, datatypes, nullability, field IDs). No 
change
      needed there.
   
   3. Native planner (aggregate path). `planner.rs` `AggExprStruct::Min`/`Max`
      (~line 3264/3278) call `min_max_udaf(datatype, is_max)`, which routes 
non-float
      types to DataFusion's `min_udaf()`/`max_udaf()`. The child is wrapped in a
      `CastExpr` to the (same) struct datatype; struct -> struct cast is 
supported
      (`cast.rs:401`, `cast_struct_to_struct`). So the identity cast is fine.
   
   4. Native aggregate exec. Comet uses DataFusion's `AggregateExec`
      (`planner.rs:1767`/`1920`) with Partial/Final/Single modes. State for 
struct
      min/max is a single struct column produced/merged by
      `MinMaxStructAccumulator.state()` / `merge_batch`. The restrictive
      `spark_aggregate_state.rs` path is only for `collect_list`/`collect_set`, 
not
      min/max, so it does not apply.
   
   5. DataFusion accumulator. `MinMaxStructAccumulator` implements both
      `Accumulator` and `GroupsAccumulator`, skips null inputs (matches Spark's
      null-skipping for min/max), and reports memory via `size()` (tracks
      `total_data_bytes` from `get_array_memory_size()`).
   
   6. Window path is NOT affected and stays off. `CometWindowExec` gates 
min/max on
      `minMaxDataTypeSupported` (no struct), which we are not changing. Good -
      avoids sliding-window retract concerns for structs.
   
   ## Proposed change
   
   Add a `StructType` case to `minMaxSupportLevel` (plus update
   `minMaxUnsupportedReasons`). Classify it carefully:
   
   - `Compatible` only when the struct contains no `FloatType`/`DoubleType` 
anywhere
     (recursively) and all leaf fields are themselves min/max-supported with 
default
     collation strings.
   - Otherwise keep `Unsupported` (do not silently opt in), or use 
`Incompatible`
     (opt-in via `spark.comet.expression.<Name>.allowIncompatible`) if we want 
power
     users to enable the known-divergent cases.
   
   Recurse into nested structs; require every leaf to pass the same gate.
   
   ## Parity risks and required guards
   
   DataFusion compares structs with `partial_cmp_struct` (`datafusion/common`),
   which flattens fields and compares in order with Arrow `lt`/`eq` kernels. 
Two real
   divergences from Spark's `InterpretedOrdering`:
   
   1. Float/double NaN. Arrow follows IEEE (NaN comparisons are false); Spark 
orders
      NaN as the largest value. A float/double field inside the struct can pick 
a
      different row than Spark. GUARD: reject (or mark Incompatible) any struct 
that
      contains a float/double field, at any nesting depth.
   
   2. Null fields. `partial_cmp_struct` skips a field when either side is null 
(the
      `lt`/`eq` result is invalid), effectively treating null as "indifferent".
      Spark orders null as the smallest value. On ties where the deciding field 
is
      null, the two engines can pick different rows. For the caspian job the 
ordering
      field (`ts`) is always `> 0` and non-null, so this does not bite there, 
but a
      general feature must document it (and ideally test it).
   
   Also carry over the existing non-default-collation string guard for string 
fields
   inside the struct.
   
   ## Memory / performance note (native review)
   
   `MinMaxStructAccumulator` stores one `StructArray` per group. At high group
   cardinality (the caspian job groups by `discoveredUrl` across 16000 shuffle
   partitions) the per-group `StructArray` overhead is larger than a primitive
   accumulator's. `size()` does account `total_data_bytes`, so the memory pool 
sees
   it, but worth a benchmark on a high-cardinality struct min/max to check 
off-heap
   pressure and spill behavior. Compare against the string min/max memory caveat
   noted in apache/datafusion-comet#6936.
   
   ## Test plan
   
   Tests must compare Comet results against Spark and live in SQL test files 
where
   possible.
   
   - Struct min/max with grouping, struct of `(long, string, string)` - the 
caspian
     shape. Verify exact parity with Spark.
   - Nullable vs non-nullable struct fields; whole-struct null (from a guarding
     `when(...)`) and field-level nulls.
   - Nested structs (struct containing a struct), and struct containing 
array/map
     leaves if we choose to allow them.
   - Boundary values on the ordering field: min/max long, decimals at max 
precision,
     dates and timestamps including timestamp with timezone and timestamp_ntz, 
and
     the [1980, now] range this job validates.
   - Non-ASCII / CJK strings in string fields (raw-byte vs collation ordering).
   - Float/double field present -> confirm it falls back (or is gated) and does 
not
     silently diverge on NaN.
   - Ties on the ordering field with differing null fields -> document/assert 
the
     chosen behavior.
   - Mixed partial/final: `supportsSparkPartialToNativeFinal` and
     `supportsNativePartialToSparkFinal` are both true for Min/Max, so cover
     Spark-partial -> native-final and native-partial -> Spark-final; confirm 
the
     struct state format round-trips through Comet shuffle.
   - Failure-path / error-message checks where Spark raises structured errors.
   
   ## Version / build context
   
   - `#4565` (SortAggregateExec support) and `#6927` (native string/binary 
min/max)
     are on `apple/main-apple` but not in any release tag yet. The image
     `...comet-1.2.0.0-apple-20261005.164715-1` was built 2026-10-05, before 
both.
   - Verified against `datafusion-functions-aggregate` 55.2.0 (Comet's pinned
     version). `min_max.rs` dispatches `Struct(_)` to `MinMaxStructAccumulator`
     (lines 336/630), `flatten` recurses nested structs, and 
`partial_cmp_struct`
     (datafusion/common/src/scalar/mod.rs) is byte-identical to 53.1.0 - null 
fields
     are still skipped and float NaN still follows Arrow IEEE. The parity
     classification above (guard against float/double, document null-field ties)
     stands for 55.2.0.
   
   ## Out of scope
   
   - Window-function min/max over structs (separate gate, keep off).
   - Rewriting `max(struct(order, payload))` into native `max_by`/`min_by` 
(Comet has
     `CometMaxMinBy`, but it requires fixed-length value and ordering, so it 
does not
     cover this; and it would need a Catalyst rewrite rule).
   - Any change to the caspian application.
   
   
   ### Describe the potential solution
   
   _No response_
   
   ### Additional context
   
   _No response_
   
   ### Willingness to contribute
   
   None


-- 
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