Skip to content

feat: native collect_list / array_agg aggregate#4720

Open
andygrove wants to merge 11 commits into
apache:mainfrom
andygrove:feat-collect-list
Open

feat: native collect_list / array_agg aggregate#4720
andygrove wants to merge 11 commits into
apache:mainfrom
andygrove:feat-collect-list

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #2524.

Rationale for this change

collect_list and its alias array_agg are common aggregate functions that previously fell back to Spark, breaking native execution for many real workloads (notably plans that group rows into arrays before further processing). Adding native support keeps these queries on the Comet path.

What changes are included in this PR?

This change was scaffolded with the implement-comet-expression skill.

  • Add CollectList message to expr.proto and a new collectList = 18 arm in the AggExpr oneof.
  • Add CometCollectList serde in aggregates.scala and register classOf[CollectList] -> CometCollectList in QueryPlanSerde.
  • Wire the native side to datafusion_spark::function::aggregate::collect::SparkCollectList, the upstream Spark-compatible accumulator (Spark 3.4 through 4.1 use ignore_nulls = true semantics that match it). No Comet-local Rust function is added.
  • Extend the Partial-mode adjustOutputForNativeState fix in operators.scala to cover CollectList (same pattern already used for CollectSet: native produces ArrayType(elementType) while Spark declares the buffer as BinaryType).
  • Mark collect_list and array_agg as supported in the user-guide expression page.
  • Add an audit entry under docs/source/contributor-guide/expression-audits/agg_funcs.md covering Spark 3.4.3, 3.5.8, 4.0.1, 4.1.1.

How are these changes tested?

  • New SQL fixture spark/src/test/resources/sql-tests/expressions/aggregate/collect_list.sql exercises ~30 queries across types (boolean, byte/short/int/bigint, float/double including NaN/Inf/-0, string, binary, decimal up to (38,0), date, timestamp, struct, nested array), GROUP BY, NULLs, all-NULL groups, empty tables, single-row, mixed aggregates, multiple collect_list columns, DISTINCT, HAVING, the array_agg alias, INT/BIGINT boundary values, and an SPARK-17641 null-filter regression. The fixture runs under ConfigMatrix: parquet.enable.dictionary=false,true, so each query executes twice.
  • All 21 aggregate CometSqlFileTestSuite tests pass (./mvnw test -Dsuites="org.apache.comet.CometSqlFileTestSuite expressions/aggregate" -Dtest=none -Pspark-3.5), confirming no regression to collect_set or other aggregates.
  • cargo clippy --all-targets --workspace -- -D warnings is clean.

Wires Spark's CollectList aggregate to datafusion-spark's
SparkCollectList. array_agg, registered as a SQL alias of
CollectList in FunctionRegistry, is also covered.

Closes apache#2524.
@andygrove
andygrove force-pushed the feat-collect-list branch from bf6b0c1 to c9c19e3 Compare June 24, 2026 16:31
@andygrove
andygrove marked this pull request as ready for review June 24, 2026 17:01
@andygrove
andygrove marked this pull request as draft June 24, 2026 19:26
…gates

A distinct aggregate combined with collect_list/collect_set produces a
multi-stage plan (Partial -> PartialMerge -> Final). CollectList/CollectSet
declare a BinaryType buffer in Spark but produce a native ArrayType state, so
Comet cannot read a Spark-produced Binary buffer, nor round-trip its own
ArrayType buffer across the intermediate PartialMerge stages. Both led to
native crashes ("could not cast Binary to List" / "cast List to Binary").

Force these multi-stage aggregates to fall back to Spark consistently:
- tag the feeding Partial when a PartialMerge stage of CollectList/CollectSet
  is present (CometExecRule.tagUnsafePartialAggregates), and
- fall back a PartialMerge stage whose buffer was produced by a Spark partial
  (CometBaseAggregate.doConvert).

Two-stage collect_list/collect_set continue to run natively.

Patch the upstream SPARK-22223 plan-shape test to disable Comet, since native
collect_list removes the ObjectHashAggregateExec it asserts on.

Enabling fully-native multi-stage execution is tracked in apache#4724.
@andygrove
andygrove marked this pull request as ready for review June 25, 2026 13:54
@mbutrovich
mbutrovich self-requested a review July 1, 2026 14:36

@mbutrovich mbutrovich 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.

First pass. Mostly want to discuss Spark 4.2 or if we should punt.

inputs: Seq[Attribute],
binding: Boolean,
conf: SQLConf): Option[ExprOuterClass.AggExpr] = {
val child = expr.children.head

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 does not check CollectList.ignoreNulls, and the upstream SparkCollectList hardcodes ignore_nulls = true (datafusion/spark/src/function/aggregate/collect.rs). On Spark 3.4 through 4.1 CollectList has no ignoreNulls field so this is correct. On Spark 4.2 it gains ignoreNulls: Boolean = true and RESPECT NULLS becomes reachable from SQL. FunctionResolution.applyIgnoreNulls resolves it:

case collectList: CollectList => collectList.copy(ignoreNulls = ignoreNulls)

So collect_list(x) RESPECT NULLS sets ignoreNulls = false, which keeps null elements. Comet drops them and returns a different result from Spark with no fallback. 4.2 releases soon, so we might want to tackle this now. The field is absent on 3.4-4.1, so reading it needs a version shim (CometExprShim) rather than a direct expr.ignoreNulls reference. CometCollectSet has the same gap on 4.2, so consider covering both in the shim.

CometFirst (line 249) and CometLast (line 285) already thread ignoreNulls through the proto. First/Last carry the field on every supported Spark version, so no shim is needed there, which is the one extra wrinkle for CollectList/CollectSet. Same wiring shape applies once the field is shimmed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 9e680c4. Added a CometCollectShim.ignoreNulls shim across the per-version source dirs: 3.4 through 4.1 have no such field so it returns true, and the spark-4.2 shim reads agg.ignoreNulls. getSupportLevel on both CometCollectList and CometCollectSet now returns Unsupported when ignoreNulls = false, so collect_list(x) RESPECT NULLS / collect_set(x) RESPECT NULLS on 4.2 falls back to Spark instead of silently dropping nulls.

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.

ignore nulls is not supported by spark for those functions, Comet shouldn't be handling it as this is misleading

* are therefore only safe to run natively when every stage runs in Comet and there are at most
* two stages (Partial + Final).
*/
def hasIncompatibleBufferAgg(aggExprs: Seq[AggregateExpression]): Boolean = {

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.

Nice to factor this out and share it between CometExecRule and operators.scala instead of duplicating. Minor naming thought: "incompatible buffer" is a bit generic. The method really means "produces a native ArrayType state where Spark declares BinaryType, so it cannot round-trip a Spark-produced intermediate buffer." Something like hasNativeArrayBufferAgg would carry more of that at the call sites. The doc comment is clear either way, so take it or leave it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Renamed to hasNativeArrayBufferAgg in 9e680c4.

-- ============================================================

query
SELECT grp, sort_array(collect_list(DISTINCT i)) FROM cl_src_int GROUP BY grp ORDER BY grp

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.

For the record, no change needed. A lone collect_list(DISTINCT i) does not create a PartialMerge stage on collect_list itself. Spark does the dedup with group-by-only stages and the collect still runs plain Partial then Final, so this query stays fully native and does not touch the hasIncompatibleBufferAgg fallback. The fallback path needs a distinct aggregate combined with the collect, which the new CometAggregateSuite test covers over both LocalTableScan and Parquet sources. Coverage across the two files is good.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, no change needed. The lone collect_list(DISTINCT i) path stays fully native, and the aggregate SQL-file tests plus the CometAggregateSuite distinct-plus-collect fallback test still pass after merging the latest apache/main (which reworked the mixed-execution guard).

andygrove added 2 commits July 1, 2026 16:52
# Conflicts:
#	dev/diffs/3.4.3.diff
#	docs/source/contributor-guide/expression-audits/agg_funcs.md
#	native/proto/src/proto/expr.proto
#	spark/src/main/scala/org/apache/comet/serde/aggregates.scala
#	spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
Spark 4.2 adds an ignoreNulls field to CollectList and CollectSet, and
collect_list(x) RESPECT NULLS sets it to false, keeping null elements. The
native path delegates to SparkCollectList/SparkCollectSet, which always drop
nulls, so it would silently return a different result from Spark. Add a
per-version CometCollectShim that reads ignoreNulls (always true on Spark 3.4
through 4.1, where the field is absent) and fall back to Spark in
getSupportLevel when it is false.

Also rename QueryPlanSerde.hasIncompatibleBufferAgg to hasNativeArrayBufferAgg
to describe what it detects: an aggregate whose native ArrayType state cannot
round-trip Spark's declared BinaryType buffer.
# Conflicts:
#	dev/diffs/3.4.3.diff
#	dev/diffs/3.5.8.diff
#	dev/diffs/4.0.2.diff
#	dev/diffs/4.1.2.diff
#	docs/source/user-guide/latest/expressions.md
#	native/proto/src/proto/expr.proto
#	spark/src/main/scala/org/apache/comet/serde/aggregates.scala
#	spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
Comment thread dev/diffs/3.5.8.diff Outdated
@andygrove
andygrove marked this pull request as draft July 9, 2026 21:10
andygrove added 4 commits July 9, 2026 15:16
The SPARK-22223 ObjectHashAggregate test asserts on the executed plan.
With Comet enabled, collect_list runs natively as CometHashAggregateExec,
so ObjectHashAggregateExec is no longer present. Rather than disabling
Comet, update the operator assertion to also accept CometHashAggregateExec,
matching the pattern already used elsewhere in the diffs. The exchange
assertion already matches ShuffleExchangeLike, which CometShuffleExchangeExec
implements, so the single-shuffle check still holds.
Adding CollectList to the comment pushed a line past the column limit
during the apache/main merge; reflow to satisfy spotless.
collect_list and collect_set build their result list with all element
fields marked nullable, but SparkCollectList/SparkCollectSet derive the
return type from the child, preserving non-nullable nested fields. When
the child is a nested type with a non-nullable inner field (e.g. a struct
field built from non-nullable columns), the declared aggregate output
disagrees with the array the accumulator produces, and the grouped native
AggregateExec fails validating its output batch with "column types must
match schema types".

Cast the collect child to the all-nullable variant of its type so the
declared and produced types stay consistent.
@andygrove
andygrove marked this pull request as ready for review July 14, 2026 20:04

@mbutrovich mbutrovich 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.

Thanks @andygrove, the delegation to SparkCollectList and the nullability coercion are clean and the SQL coverage is excellent. My main comments are on the two multi-stage fallback guards. The one in operators.scala looks unreachable now that the general mixed-execution guard has merged, so I think it can go. The one in CometExecRule is the opposite, it's catching a case the general path misses, so it should stay with a comment explaining why. The rest are minor.

}
}

// CollectList/CollectSet declare their buffer as BinaryType in Spark but produce a native

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.

Isn't this block unreachable? The missingCometProducer guard at line 1632 already returns None for this case. A PartialMerge stage makes consumesBuffers true, and CollectList/CollectSet are always in aggsNotSupportingMixedExecution since they don't override supportsMixedPartialFinal. Looks like it predates the merge that brought that guard over from main. Can we drop it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Dropped. You are right, the general missingCometProducer + aggsNotSupportingMixedExecution guard just above already returns None for the same case, so this block was unreachable.

}
}

// CollectList/CollectSet round-trip an ArrayType buffer that Spark declares as BinaryType.

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.

Could you add a comment on why this is separate from the tagging block just above? They look like duplicates. The difference is that the block above only tags when the Final can't convert, and canAggregateBeConverted skips the child-native check, so an all-native collect_list Final converts and slips past it. This block is what catches that all-native distinct case, which is the Parquet half of the new suite test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added a comment on the block explaining why it is separate: the tagging block above only fires when the Final cannot convert, but canAggregateBeConverted skips the child-native check, so an all-native distinct collect_list Final converts and slips past. This block catches that case.


/**
* Returns true if any aggregate function produces a native intermediate buffer whose Arrow type
* (e.g. ArrayType for CollectList/CollectSet) differs from the BinaryType that Spark declares

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.

The comment describes this as matching any agg with a native ArrayType buffer, but it only matches the collect functions. Percentile has the same buffer shape (see adjustOutputForNativeState) and isn't matched. It's fine in practice since percentile with distinct already passes, but the comment reads broader than the code. Worth narrowing the comment, or inlining the match since it's only used in one place now.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Narrowed the doc comment to describe what the code actually matches (CollectList/CollectSet) and noted that Percentile has the same buffer shape but is intentionally not matched here since it already passes through the general mixed-execution guard.

* (`ignoreNulls = false` via `RESPECT NULLS`), so this shim reports the actual value. The serde
* falls back to Spark when it is `false`, since the native path always drops nulls.
*/
object CometCollectShim {

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.

These four files (3.4 through 4.1) are identical and only 4.2 differs. Could the 3.4/3.5 pair share the existing spark-3.x dir the way CometTypeShim does, so this is one fewer copy to keep in sync?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Moved the identical spark-3.4/spark-3.5 copies to spark-3.x. spark-4.x is used by spark-4.0/4.1/4.2 too, and 4.2 needs the different shim, so I could not consolidate 4.0/4.1 there — happy to add a fresh shared dir if you would prefer that, but it seemed like more build machinery than the reduction was worth.

CREATE TABLE cl_src_nulls(val int, grp string) USING parquet

statement
INSERT INTO cl_src_nulls VALUES

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.

Could we add a collect_list(x) FILTER (WHERE ...) case, and something that exercises a map input? The map path hits the Map arm in make_all_fields_nullable that nothing covers yet. sort_array won't order a map, so it'd need spark_answer_only or a size check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added collect_list FILTER (WHERE i > 1) per-group and a map-input case (collect_list(m) on a map<string, int> table with two entries and a null row). The map assertion is a spark_answer_only size-only check since sort_array cannot order a MapType.

…m; add FILTER + map tests

operators.scala: drop the CollectList/CollectSet PartialMerge fallback block. It is unreachable now that the general missingCometProducer + aggsNotSupportingMixedExecution guard just above returns None for the same case. CometExecRule.scala: add a comment on the collect-specific tagging block explaining it is separate from the tagging block just above because canAggregateBeConverted skips the child-native check, so an all-native distinct collect chain would otherwise slip past. QueryPlanSerde.scala: narrow the hasNativeArrayBufferAgg doc comment to describe what the code actually matches and note that Percentile has the same shape but is not matched here. Consolidate CometCollectShim: move the identical spark-3.4 and spark-3.5 copies to spark-3.x. Add collect_list FILTER (WHERE ...) test and a map-input test hitting make_all_fields_nullable's Map arm (size-only via spark_answer_only, since sort_array cannot order a MapType).

@mbutrovich mbutrovich 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.

Approved pending CI. Thanks @andygrove!

.getOrElse(Compatible())
// The native path always drops null inputs. Spark 4.2 adds `RESPECT NULLS`
// (`ignoreNulls = false`), which keeps nulls, so fall back there.
if (!CometCollectShim.ignoreNulls(expr)) {

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 code is confusing

spark.sql("SELECT collect_list(c) IGNORE NULLS FROM VALUES (1),(NULL) AS t(c);").show(false)
org.apache.spark.sql.AnalysisException: [INVALID_SQL_SYNTAX.FUNCTION_WITH_UNSUPPORTED_SYNTAX] Invalid SQL syntax: The function `collect_list` does not support IGNORE NULLS.

it is always dead code

* added `ignoreNulls` (settable to `false` via `RESPECT NULLS`), handled by the spark-4.2 shim.
*/
object CometCollectShim {
def ignoreNulls(agg: CollectList): Boolean = true

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.

we shouldn't be having that

@comphead comphead 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.

Thanks @andygrove I think we should leave IGNORE NULLS handling to Spark itself which would fail

@andygrove

Copy link
Copy Markdown
Member Author

This guard is for RESPECT NULLS (ignoreNulls = false), not IGNORE NULLS, and it only becomes reachable on Spark 4.2.

Spark 4.2 added an ignoreNulls field to CollectList/CollectSet. FunctionResolution.applyIgnoreNulls now has explicit cases for them:

case collectList: CollectList => collectList.copy(ignoreNulls = ignoreNulls)
case collectSet: CollectSet  => collectSet.copy(ignoreNulls = ignoreNulls)

so SELECT collect_list(c) RESPECT NULLS FROM ... is accepted on 4.2 and produces CollectList(ignoreNulls = false), whose buffer preserves nulls (bufferContainsNull = !ignoreNulls). Comet's native aggregate always drops nulls, so it has to fall back in that case, which is what this branch does.

Your IGNORE NULLS example throws because it runs on Spark 4.1 or earlier, where CollectList isn't in the applyIgnoreNulls match and instead hits case _ if ignoreNulls => throw ... "IGNORE NULLS". On those versions the field doesn't exist, so CometCollectShim hardcodes ignoreNulls = true and this branch is an unreachable no-op. It's routed through the shim precisely so it only activates on 4.2. (On 4.2 your exact IGNORE NULLS query is actually accepted too, since it resolves to the default.)

I'll add a 4.2-only SQL test exercising collect_list(c) RESPECT NULLS plus a code comment spelling out the version split so this is clearer in the source.

For transparency: I used an LLM to help trace the Spark 4.2 resolution path while working through this.

Add a Spark 4.2-gated SQL file test asserting that collect_list and
collect_set with RESPECT NULLS fall back to Spark with Spark-identical
results, and expand the getSupportLevel comments to explain that the
fallback branch is only reachable on 4.2+ (a no-op on 3.4 through 4.1).
@andygrove

Copy link
Copy Markdown
Member Author

Pushed a499164d0 adding test coverage and clearer comments for the RESPECT NULLS fallback.

New SQL file test spark/src/test/resources/sql-tests/expressions/aggregate/collect_respect_nulls.sql, gated with -- MinSparkVersion: 4.2:

  • collect_list(v) RESPECT NULLS and collect_set(v) RESPECT NULLS (global, per-group, and an all-null group) use query expect_fallback(...), asserting both the exact fallback reason (... with RESPECT NULLS (ignoreNulls = false) is not supported) and Spark-identical results.
  • A closing default-behavior collect_list query stays native (checkSparkAnswerAndOperator) to prove the fallback is specific to RESPECT NULLS rather than a blanket collect fallback.

This runs in the Spark 4.2, JDK 17 job's expressions bucket (which includes CometSqlFileTestSuite) and is skipped on 3.4 through 4.1, where the ignoreNulls field does not exist and the branch is an unreachable no-op.

I also expanded the getSupportLevel comments on CometCollectList/CometCollectSet to spell out that the fallback is only reachable on 4.2+.

For transparency: the analysis and these tests were done with LLM assistance.

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.

Integrate collect_list/array_agg to Comet

3 participants